初探JVM
JVM探究
- 请你谈谈你对JVM的理解?java8虚拟机和之前的变化更新?
- 什么是OOM,什么是栈溢出StackOverFlowError?怎么分析?
- JVM的常用调优参数?
- 内存快照如何抓取,怎么分析Dump文件?
- 谈谈JVM中,类加载器的认识?
JVM的位置
JVM是什么
-
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,
它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。 -
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。
Java语言使用Java虚拟机屏蔽了与具体平台相关的信息;
使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
JVM体系结构
Class File:
Class File 是平台无关的二进制文件,包含着能被JVM执行的字节码,其中多字节采用大端序,字符使用一种改进的UTF-8编码。
Class文件精确的描述了一个类或接口的信息,其中包括:
- 常量池:数值和字符串字面常量,元数据如类名、方法名称、参数,以及各种符号引用
- 方法的字节码指令,参数个数,局部变量,最大操作数栈深度,异常等信息
ClassLoader:
类加载器,JVM在类首次使用时动态的加载、链接和初始化。
JVM默认的加载模型是双亲委派模型,类加载器之间存在父子关系的层次结构,内部使用组合实现。
此外还有其他的加载方式,比如Servlet加载,它先尝试自己加载,不成功再委派上层加载器,类隔离;
OSGI加载器之间是一种网状的依赖关系,没有上下层的区分,比较灵活。
加载
加载就是将Class文件表示的类或接口,在JVM方法区中创建一个与之对应的java.lang.Class对象;
像Class.forName()、ClassLoader.loadClass()、反射都能触发类加载。当触发一个类加载时,详细的过程如下:
- 检查类是否已经被加载
- 将加载请求委派给上层类加载器
- 自己尝试搜索类并加载
当ClassLoader在classpath中未找到类文件,会抛出ClassNotFoundException;
当类A引用类B,类A已经成功加载,但是加载B时未找到类文件,会抛出NoClassDefFoundError。
JVM有以下几种类加载器:
- Bootstrap ClassLoader,启动类加载器,加载 <java_home>\jre\lib 中 Java 核心类库
- Extension ClassLoader,扩展类加载器,加载 <java_home>\jre\lib\ext 中的类
- System ClassLoader,系统类加载器,也叫应用程序类加载器(Application class loader),加载 CLASSPATH 环境变量中的类
链接
- 验证:确保class文件的正确性。
- 准备:为类静态字段分配内存并初始化为默认值,不会执行任何字节码指令。
- 解析:将符号引用转为方法区(运行时常量池)直接引用
初始化
- 执行类初始化方法,即赋值静态字段,执行静态块,顺序按照其定义的先后。父类的静态域会先于子类静态域初始化。
- 至此,一个类或接口被加载到了内存中,JVM会保证整个过程是线程安全的。需要注意的是整个过程没有涉及到任何实例对象。
运行时数据区
- Method Area:线程共享,存储运行时常量池、类字段和方法信息、静态变量和方法的字节码,是堆的逻辑组成部分,这部分的垃圾回收是可选的。
值得一提的是Hotspot JVM自JDK8之后,调整了这部分内存的内容,class meta-data的分配使用本地内存,interned String和类静态变量移动到了Java堆。 - 运行时常量池:对于JVM来说具有核心作用,基本上涉及到方法或字段,JVM就会在运行时常量池中搜索其具体的内存地址。
- Heap:线程共享,存储实例对象,实例变量以及数组,是垃圾回收的主要区域。
- JVM Stack:线程私有,用于存储栈帧,当方法被调用时会创建一个栈帧入栈,栈帧由以下几部分组成:
- 局部变量表:从0开始存储this、方法参数、局部变量。
- 操作数栈:方法的工作区,在操作数栈和局部变量之间交换数据,存储中间结果,操作数栈深度在编译时就能确定。
- 帧数据:方法返回值,异常分派,以及当前方法所在类运行时常量池的引用。
- PC Register:线程私有,保存当前指令地址,执行后指向下一条指令地址。
- Native Method Stack:线程私有,存储本地方法信息,C或C++栈。
执行引擎:
读取、翻译、执行字节码。JVM基于栈架构,这个栈就是操作数栈,字节码指令就是通过它进行各种运算。此外还有基于寄存器的虚拟机。
- Interpreter,翻译:解释字节码比较快,执行慢,缺点是每次方法调用都要重新翻译解释一遍。
- JIT Compiler,即时编译:找出程序中频繁调用的热点方法,将字节码编译成本地代码,提高性能。
- Garbage Collector,垃圾收集器:回收无效对象,判断对象是否可回收,可采用不同的垃圾回收算法。
本地方法接口和库:
JNI,调用本地方法,c/c++库;执行引擎所需的本地方法库。
类加载器
加载Class文件
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。
如果两个类来源于同一个Class文件,只要加载它们的类加载器不同,那么这两个类就必定不相等。
启动类加载器(Bootstrap ClassLoader): 这个类加载器负责将存放在\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的;
并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。getClassLoader()方法返回null。
扩展类加载器(Extension ClassLoader): 这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载\lib\ext目录中的;
或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值;
所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器;
如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派机制
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。
这里类加载器之间的父子关系一般不会以继承的关系来实现,而都是使用组合的关系复用父类加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成;
因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是java类随着它的类加载器一起具备了一种带有优先级的层次关系。
双亲委派模型的实现:
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
//1 首先检查类是否被加载
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//2 没有则调用父类加载器的loadClass()方法;
c = parent.loadClass(name, false);
} else {
//3 若父类加载器为空,则默认使用启动类加载器作为父加载器;
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
//4 若父类加载失败,抛出ClassNotFoundException 异常后
c = findClass(name);
}
}
if (resolve) {
//5 再调用自己的findClass() 方法。
resolveClass(c);
}
return c;
}
java.lang.ClassLoader 的 loadClass() 实现了双亲委派模型的逻辑,因此自定义类加载器一般不去重写它,但是需要重写 findClass() 方法。
- 双亲委派模型
- 启动类加载器有C++代码实现,是虚拟机的一部分。负责加载\lib下的类库
- 其他的类加载器有java语言实现,独立于JVM,并且继承ClassLoader
- extention ClassLoader负责加载\lib\ext目录下的类库
- application ClassLoader 负责加载用户路径下(ClassPath)的代码
- 不同的类加载器加载同一个class文件会导致出现两个类。而java给出解决方法是下层的加载器加委托上级的加载器去加载类,如果父类无法加载(在自己负责的目录找不到对应的类),而交还下层类加载器去加载。
Native
public class Demo {
public static void main(String[] args) {
new Thread(()->{
},"my thread name").start();
}
/**native: 凡是带了native 关键字的,说明java的作用范围达不到了,回去调用底层C语言的库!
* 会进入本地方法栈
* 调用本地方法本地接口 JNI (Java Native Interface)
* JNI作用:扩展Java的使用,融合版不同的编程语言为Java所用! 最初想融合C 、C++ 。
* Java诞生的时候C、C++ 横行,想要立足!必须要有调用C、C++的程序!
* 它在内存区域中专门开辟了一块标记区域:Native Method Stack, 登记 native 方法
* 在最终执行的时候,加载本地方法库中的方法通过JNI
*/
//Java程序驱动打印机,管理系统,掌握即可,在企业级应用中较为少见!
private native void start0();
}
凡是带了native关键字的,说明java的作用范围达不到!是去调用底层C语言的库!
JNI:Java Native Interface(Java本地方法接口)
凡是带了native关键字的方法就会进入本地方法栈,其他的就是Java栈;
Native Interface 本地接口
本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序,Java在诞生的时候是C/C++横行
的时候,想要立足,必须有调用C、C++的程序。于是就在内存中专门开辟了一块区域处理标记为native的代码,
它的具体做法是在 Native Method Stack 中登记native方法,在(Execution Engine)执行引擎执行的时候加载
Native Libraies.
目前该方法使用越来越少了,除非是和硬件有关的应用。比如通过java程序驱动打印机或Java系统管理生产设备;
在企业级应用中已经比较少见!因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用web Service等。
Native Method Stack
它的具体做法是Native Method Stack 中登记native方法,在(Execution Engine)执行引擎执行的时候加载
Native Libraies.[本地库]
PC寄存器
程序计数器:Program Counter Register
每个线程都有一个程序计数器,是线程私有的,就是一个指针!指向方法区中的方法字节码(用来存储指向像一条指令的地址,也即将
要执行的指令代码),在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计
方法区
Method Area 方法区
方法区是被所有线程共享,所有字段和方法区字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,
简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;
静态变量、常量,类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,
与方法区无关!
栈
对象的实例化过程
-
对象实例化过程 其实就是执行类构造函数 对应在字节码文件中的init()方法(称之为实例构造器);init()方法由非静态变量、非静态代码块以及对应的构造器组成;
- init()方法可以重载多个,类有几个构造器就有几个init()方法
- init()方法中的代码执行顺序为:父类变量初始化,父类代码块,父类构造器,子类变量初始化,子类代码块,子类构造器。
-
静态变量,静态代码块,普通变量,普通代码块,构造器的执行顺序
- 具有父类的子类的实例化顺序
栈: 先进后出、后进先出 :抽象类似(桶、弹夹)
队列: 先进先出 (FIFO:First Input First Output)
抽象理解一点就是:
喝多了吐就是栈,吃多了拉就是队列
栈:栈内存,主管程序的运行,声明周期和线程同步;
线程结束,栈内存也就释放,对于栈来说,不存在垃圾回收问题
一旦线程结束,栈就Over!
栈:8大基本类型 + 对象引用 + 实例的方法
栈运行原理:栈帧
- 栈帧由三部分组成:局部变量表、操作数栈以及帧数据。
- 每个方法涉及的局部变量表和操作数栈的大小取决于每个具体的方法,但是大小在编译后便已确定,而且已经包含在class文件中。
- 当JVM执行一个方法时,它会检查class中的数据,以便确定一个方法执行时在局部变量表和操作数栈中所需存储的word size。
然后,JVM会为当前方法创建一个size相对应的栈帧,然后把它push到栈顶。
栈溢出: 线程请求的栈深度大于虚拟机允许的最大深度 StackOverflowError(错误是很严重的,必要情况下必须让JVM停下来)
栈、堆、方法区的交互关系:
三种JVM
- Sun公司 Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode)
- BEA JRockit
- IBM J9VM
堆
Heap,一个JVM只有一个堆内存,堆内存的大小是可以调节的。
类加载器读取文件后,一般会把类、方法、常量、变量,保存我们所有引用类型的真实对象;
堆内存中还要细分为三个区域:
- 新生区 (伊甸园区)Young/New
- 养老区 old
- 永久区(JDK1.8好像被移除了,改名元空间) Perm
- JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)。
- 年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。
- 堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。
- 非堆内存用途:永久代,也称为方法区,存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。
在JDK1.8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代上类似,都是方法区的实现,他们最大区别是:元空间并不在JVM中,而是使用本地内存。
元空间有注意有两个参数:
- MetaspaceSize :初始化元空间大小,控制发生GC阈值
- MaxMetaspaceSize : 限制元空间大小上限,防止异常占用过多物理内存
为什么移除永久代?
移除永久代原因:为融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。
有了元空间就不再会出现永久代OOM问题了!
分代概念
新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,
存活下来的对象移动到Survivor0区,Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1区,这样保证了一段时间内总有一个survivor区为空。
经过多次Minor GC仍然存活的对象移动到老年代。
老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。
- Minor GC : 清理年轻代
- Major GC : 清理老年代
- Full GC : 清理整个堆空间,包括年轻代和永久代
- 所有GC都会停止应用所有线程。
为什么分代?
将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。
为什么survivor分为两块相等大小的幸存空间?
主要为了解决碎片化。如果内存碎片化严重,也就是两个对象占用不连续的内存,已有的连续内存不够新对象存放,就会触发GC。
GC垃圾回收,主要是在伊甸园区和养老区!
假设内存满了,OOM (堆内存不够!)
新生区
- 类: 诞生 和成长的地方,甚至死亡;
- 伊甸园,所有的对象都是在伊甸园区new出来的!
- 幸存者区(0,1)
老年区
真理:经研究,99%的对象都是临时对象!
永久区
这个区域常驻内存的。用来存放JDK自身携带的Class对象。Interface元数据,存储的是Java运行时的一些环境或
类信息;这个区域不存在垃圾回收!关闭JVM虚拟机就会释放这个区域的内存!
一个启动类,加载了大量的第三方Jar包,例Tomcat部署了太多的应用,大量的动态生成的反射类(代理模式)。不断
的被加载,直到内存满,就会出现OOM!
- JDK1.6 以前: 永久代,常量池是在方法区;
- JDK1.7 : 永久代,但是慢慢退化了,去永久代,常量池在堆中
- JDK1.8 之后: 无永久代,常量池在元空间
元空间:逻辑上存在:物理上不存在
public class Demo2 {
public static void main(String[] args) {
//返回虚拟机试图使用的最大内存
long max = Runtime.getRuntime().maxMemory(); //字节 1024 * 1024
//返回JVM的初始化总内存
long total = Runtime.getRuntime().totalMemory();
System.out.println("max="+max+"字节\t"+(max/(double)1024/1024)+"MB"); //max=1900019712字节 1812.0MB
System.out.println("total="+max+"字节\t"+(total/(double)1024/1024)+"MB"); // total=1900019712字节 123.0MB
//默认情况下:分配的总内存 是电脑内存的1/4,而初始化的内存 : 1/64
}
//OOM:
//1.尝试扩大堆内存看结果
//2.分析内存,看一下那个地方出现了问题(使用工具)
// VM options : -Xms1024m -Xms1024m -XX:+PrintGCDetails
/**
* Heap
* PSYoungGen total 305664K, used 20971K [0x00000000d5900000, 0x00000000eae00000, 0x0000000100000000)
* eden space 262144K, 8% used [0x00000000d5900000,0x00000000d6d7afb8,0x00000000e5900000)
* from space 43520K, 0% used [0x00000000e8380000,0x00000000e8380000,0x00000000eae00000)
* to space 43520K, 0% used [0x00000000e5900000,0x00000000e5900000,0x00000000e8380000)
* ParOldGen total 699392K, used 0K [0x0000000080a00000, 0x00000000ab500000, 0x00000000d5900000)
* object space 699392K, 0% used [0x0000000080a00000,0x0000000080a00000,0x00000000ab500000)
* Metaspace used 3236K, capacity 4496K, committed 4864K, reserved 1056768K
* class space used 350K, capacity 388K, committed 512K, reserved 1048576K
* */
//305664K + 699392K = 1,005,056 / 1024 = 981.5M
}
如果在项目中,出现了OOM故障,那么该如何排除呢
- 能够看到代码第几行出错:内存快照分析工具,MAT(eclipse),Jprofiler
- Debug,一行行分析代码!
MAT(eclipse),Jprofiler作用:
- 分析Dump内存文件,快速定位内存泄漏;
- 获得堆中的数据
- 获得大的对象~
- ...
Jprofiler
1.可以现在IDEA 中安装插件
2.可以去官网下载:https://www.ej-technologies.com/products/jprofiler/overview.html
3.安装好了之后需要在IDEA中设置
4.然后在需要调试的代码中加上这样一行命令
5.run之后找到文件所在的本地文件夹
6.往上找找到
发现问题
测试代码中找到问题
//Dump
//-Xms 设置初始化内存分配大小 1/64
//-Xmx 设置最大分配内存,默认1/4
// -XX:PrintGCDetails //打印GC垃圾回收信息
// -XX:+HeapDumpOnOutOfMemoryError //oom Dump
//-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
public class Demo03 {
byte [] array = new byte[1*1024*1024];
public static void main(String[] args) {
ArrayList<Demo03> list = new ArrayList<>();
int count = 0;
try {
while (true){
list.add(new Demo03()); //问题所在
count = count + 1;
}
}catch (Error e){
System.out.println("count=" + count);
e.printStackTrace();
}
}
}
GC
JVM在进行GC时,并不是对这三个区域统一回收。大部分时候,回收都是新生代!
- 新生代
- 幸存区(form,to)
- 老年区
GC分两种:轻GC(普通的GC,young GC),重GC(全局的GC Full GC)
GC常见问题:
- JVM的内存模型和分区,详细到每个区放什么?
- 堆里面的分区有哪些? Eden,form,to,老年区,它们的特点?
- GC的算法有哪些? 标记清除法,标记压缩,复制算法,引用计数器,怎么用?
- 轻GC 和重GC 分别什么时候发生?
引用计数法:
复制算法:
更形象↓
- 好处:没有内存碎片
- 坏处:浪费了内存空间 。多了一半的空间永远都是空 (To).假设对象100%存活(极端情况下)就会出现一些弊端
复制算法最佳使用场景:对象存活度较低的时候:新生区!
标记清除算法
- 优点:不需要额外的空间;
- 缺点:两次扫描,严重浪费时间,会产生内存碎片;
标记压缩
在优化:
标记清除压缩
先标记清除几次
在压缩
总结
- 内存效率:复制算法-->标记清除算法-->标记压缩算法(时间复杂度)
- 内存整齐度:复制算法 = 标记压缩算法-->标记清除算法
- 内存利用率:标记压缩算法 = 标记清除算法 -->辅助算法
难道没有最优的算法吗?
没有,没有最好的算法,只有最合适的算法---->GC:分代收集算法 点我
年轻代:
- 存活率低
- 复制算法!
老年代:
- 区域大:存活率高
- 标记清除(内存碎片不是太多的情况下) + 标记压缩混合实现!
JMM
1.什么是JMM?
JMM:(Java Memory Model的缩写)
2.它是干嘛的? 通过官方文档,其他人的博客,对应的视频来学习了解!
作用:缓存一致性协议,用于定义数据读写的规则(遵守规则,就像玩游戏也要遵守规则)!
JMM定义了线程工作内存与主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory)
解决共享对象可见性的问题:volilate
3.它如何学习!
JMM:抽象的概念,理论
- JMM对这八种指令的使用,制定了如下规则:
- 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
- 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
- 不允许一个线程将没有assign的数据从工作内存同步回主内存
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
- 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
- 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
- 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
- 对一个变量进行unlock操作之前,必须把此变量同步回主内存
JMM对这八种操作规则和对volatile的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用java的happen-before规则来进行分析。
学习新东西是常态:
是什么-->为什么-->怎么学
目的:
学了能干什么-->了解什么-->得到什么
通过大量的刷题 100道题--->2、3中解题思路 3/10 分析这10个问题 学会触类旁通