JVM--Java虚拟机
Posted 萌萌滴太阳
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM--Java虚拟机相关的知识,希望对你有一定的参考价值。
文章目录
前言
先给大家看几道面试题?
1、请你谈谈你对JVM的理解?Java8的虚拟机有什么更新?
2、什么是OOM?什么是StackOverFlowError?有哪些方法分析?
3、JVM的常用参数调优你知道哪些?
4、内存快照抓取和MAT分析DUMP文件知道吗?
5、堆里面的分区:Eden,Survival from to,老年代,各自的特点?
6、GC的三种收集方法:标记清除,标记整理,复制算法的原理与特点,分别用在什么地方?
唠叨几句
每一个学习JVM的人,都渴望成功。每一个Java开发人员的终极目标都是在日常生活中深入理解JVM的运
行原理。JVM和平时的应用框架明显的区别,应用框架学习之后,可以直接拿来写项目了,就可以运行
起来看到helloworld。然而对于JVM,是一个特别枯燥的事情,还看不到直接的效果,必须要写笔记,
因为一扭头就会忘记。
JVM是一个令人望而却步的领域,因为它博大精深,涉及到的内容与知识点非常之多。虽然Java开发者
每天都在使用JVM,但对其有所研究并且研究深入的人却少之又少。然而,JVM的重要性却又是不言而喻
的。基于JVM的各种动态与静态语言生态圈已经异常繁荣了,对JVM的运行机制有一定的了解不但可以提
升我们的竞争力,还可以让我们在面对问题时能够沉着应对,加速问题的解决速度;同时还能够增强我
们的自信心,让我们更加游刃有余。
而且,如果我们想要进阶到技术专家或者更高等级,就必须要学习 JVM;
JVM的位置
- JVM是
运行在操作系统之上
的,它与硬件没有直接的交互; - JVM就是一个软件,同其他软件一样,需要安装并运行在操作系统上,java程序需要在JVM这个软件上运行。
JVM体系结构图
-
编译和运行过程:
-
本地方法栈
要调用本地方法接口
,本地方法接口
要和本地库
相连
- 下面区域一定不会有垃圾回收,因为栈用完一个就弹出去了。
- 所谓JVM的调优,其实就是在调堆区域(方法区是个特殊的堆),而且99%情况下都在调堆区域里的堆。
类加载器ClassLoader
我们先来看看一个类加载到 JVM 的一个基本结构
- 先理解Class类
类加载的作用:
将class文件字节码内容加载到内存中,并将这些静态数据转换成方法区的运行时数据结构,然后在堆中生成一个代表这个类的java.lang.Class对象,作为方法区中类数据的访问入口。
ClassLoader分类
有两种类型的类加载器
- Java虚拟机自带的加载器
1、根(启动)类加载器
Bootstrap ClassLoader :
最顶层的加载类,主要加载核心类库,也就是我们环境变量下面jre/lib下的rt.jar、resources.jar、charsets.jar和class等。
该加载器无法直接获取,因为其是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null。
2、扩展类加载器
Extention ClassLoader :
加载jre/lib/ext目录下的jar包和class文件。
3、系统(应用)类加载器
AppClassLoader:也称为SystemClassLoader。
加载当前应用的classpath的所有类。(加载你编写的类,编译后的类) - 用户自定义的类加载器
CustomClassLoader(用户自定义类加载器):
Java.lang.ClassLoader的子类(继承),可加载指定路径的class文件
双亲委派机制
-
类加载器采用的机制是双亲委派机制
-
概念
当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时(使用findClass()方法找不到要加载的类),才会尝试执行加载任务。 -
双亲委派机制的好处
采用双亲委派的一个好处
是比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象。双亲委派原则归纳一下就是:
1、可以避免重复加载
,父类已经加载了,子类就不需要再次加载
2、更加安全
,保证Java核心类库所提供的类不能被篡改。通过委托方式,不会去篡改核心.class,即使篡改也不会去加载,如果不使用该种方式,那么用户可以随意定义类加载器来加载核心api,会带来相关隐患。
Native方法
- 一个例子:
编写一个多线程类启动
public static void main(String[] args) {
new Thread(()->{
},"your thread name").start();
}
点进去看start方法的源码
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0(); //调用了一个start0方法
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {}
}
}
//这个Thread是一个类,这个方法定义在这里是不是很诡异!看这个关键字native;
private native void start0();
- 凡是带了
native关键字
的,说明 java的作用范围达不到,去调用底层C语言的库! - 具体为:(以这里的
start0()
为例)
1、JNI:Java Native Interface (Java本地方法接口)
2、Native Method Stack 本地方法栈
3、ExecutionEngine 执行引擎
本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序,Java在诞生的时候是C/C++横行的时候,想要立足,必须有调用C、C++的程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是:
凡是带了native关键字的方法就会进入本地方法栈
登记(即,start0()
进入Ntaive Method Stack);然后在 ( ExecutionEngine ) 执行引擎执行的时候加载Native Libraies,即,本地方法栈
调用本地方法接口
,本地方法接口
要和本地库
相连。
PC寄存器(程序计数器)
程序计数器:Program Counter Register
每个线程都有一个程序计数器,是线程私有的。
程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。是一个非常小的内存空间,几乎可以忽略不计
栈
- 栈:后进先出 / 先进后出
- 栈存储8大基本类型、对象的引用、实例的方法等。
栈也叫栈内存
,主管Java程序的运行,是在线程创建时创建
,它的生命期是跟随线程的生命期,线程结束栈内存也就释放
。
2、对于栈来说不存在垃圾回收问题,只要线程一旦结束,该栈就Over,生命周期和线程一致,是线程
私有的。
了解三种JVM:
- Sun公司的 HotSpot
【常用】
- BEA公司的 JRockit
- IBM公司的 J9VM
方法区
-
Method Area方法区
是 Java虚拟机规范中定义的运行时数据区域之一,它与堆(heap)一样在线程之间共享
。 -
方法区实际是堆的一部分:
Java 虚拟机规范把方法区描述为堆的一个逻辑部分
,但是它却有一个别名叫做 Non-Heap(非堆),目的是与 Java 堆区分开来。 -
JDK7 之前(永久代)用于存储已被虚拟机加载的类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。每当一个类初次被加载的时候,它的元数据都会被放到永久代中。永久代大小有限制,如果加载的类太多,很可能导致永久代内存溢出,即 java.lang.OutOfMemoryError:PermGen。
-
JDK8 彻底
将永久代移除出 HotSpot JVM
,将其原有的数据迁移至 Java Heap 或 Native Heap(Metaspace),取代它的是另一个内存区域被称为元空间(Metaspace)。元空间(Metaspace)
:元空间是方法区的在 HotSpot JVM 中的实现
, -
元空间的本质和永久代类似,都是
对 JVM 规范中方法区的实现
。不过元空间与永久代之间最大的区别
在于:元空间并不在虚拟机中,而是使用本地内存
。
可以通过-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
配置内存大小。
如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和类的加载器。
堆(Heap)
一个JVM只存在一个堆内存,堆内存的大小是可以调节的
,类加载器读取了类文件后,需要把类,方法,常变量放到堆内存中,保存所有引用类型的真实信息(引用则放到栈中)
,以方便执行器执行,堆内存分为三部分:
- 新生区 / 伊甸园区 Young Generation Space Young/New
- 养老区 Tenure generation space Old/Tenure
- 永久区 / 元空间 Permanent Space Perm / Metaspace(永久区 : JDK8 以前名称 :;JDK8 以后名称)
新生区(New/Young Space)、养老区【发生GC垃圾回收】
- 新生区是类诞生,成长,消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。
- 新生区又分为两部分:伊甸区(Eden Space)和幸存者区(Survivor Space),
所有的类都是在伊甸区被new出来的
(new的是运行时类,即通过类加载器加载进内存的.class,运行时类放在元空间,即方法区里,new的结果放在伊甸园区); - 幸存区有两个:0区 和 1区,当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行
轻垃圾回收
(Minor GC
)。将伊甸园中的剩余对象移动到幸存0区,若幸存0区也
满了,再对该区进行垃圾回收,然后移动到1区,如果1区也满了,再移动到0区,这里幸存0区和1区是一个互相交替的过程,直到幸存0区和1区都满了,再移动到养老区,若养老区也满了,那么这个时候将产生`重垃圾回收MajorGC(Full GC),进行养老区的内存清理,若养老区执行了Full GC后发现依然无法进行对象的保存,就会产生OOM异常 “OutOfMemoryError ”。
如果出现 java.lang.OutOfMemoryError:java heap space异常,说明Java虚拟机的堆内存不够,原因
如下:
1、Java虚拟机的堆内存设置不够,可以通过参数 -Xms(初始值大小),-Xmx(最大大小)来调整。
2、代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)或者死循环
永久区(Perm)【没有GC】
- 首先JDK8之前有永久区,JDK8之后无永久区,换成元空间,元空间并不在虚拟机中,而是使用本地内存。
- 永久存储区是一个常驻内存区域,用于存放JDK自身所携带的Class,Interface的元数据,也就是说它存储的是
Java运行环境必须的类信息
,被装载进此区域的数据是不会被垃圾回收器回收掉的
,关闭JVM才会释放此区域所占用的内存。 - 如果出现 java.lang.OutOfMemoryError:PermGen space,说明是 Java虚拟机对永久代Perm内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包,例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。
注意:
Jdk1.6之前: 有永久代,常量池在方法区
Jdk1.7: 有永久代,但是已经逐步 “去永久代”,常量池在堆中
Jdk1.8及之后:无永久代,常量池在元空间
- 对于HotSpot虚拟机,很多开发者习惯将方法区称之为 “永久代(Parmanent Gen)”,但严格本质上说两者不同,或者说是
永久代实现方法区而已
,即永久代是方法区的一个实现(相当于方法区是一个接口interface,永久代是方法区这个接口的实现类)
,Jdk1.7的版本中,已经将原本放在永久代的字符串常量池移走。 常量池(Constant Pool)是方法区的一部分
:Class文件除了有类的版本,字段,方法,接口描述信息
外,还有一项信息就是常量池,这部分内容将在类加载后进入方法区的运行时常量池中存放!
GC垃圾回收
准备
- 垃圾回收频率:
次数频繁Young区,次数较少Old区,基本不动Perm(永久区)区。
JVM 在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生区,因此GC按照回收的区域又分了两种类型,一种是普通的GC / 轻GC(minor GC)
,一种是全局GC / 重GC(major GC or Full GC)
普通GC:只针对新生代区域的GC
全局GC:针对老年代的GC,偶尔伴随对新生代的GC以及对永久代的GC
GC四大算法
标记清除算法(Mark-Sweep)【养老区使用的GC算法,属于重GC】【属于垃圾收集算法】
- 说明:养老区的GC一般是由
标记清除
或者是标记清除与标记整理的混合
实现
该算法分为“标记”和“清除”
两个阶段:⾸先标记
出所有不需要回收的对象,然后
在标记完成后统⼀清除
掉所有没有被标记的对象。它是最基础的收集算法
,后续的算法都是对其不⾜进⾏改进得到。
- 原理:
当堆中(具体是堆中养老区?)
的有效内存空间被耗尽
的时候,就会停止整个程序
(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
标记
:从引用根节点开始标记所有被引用的对象,标记的过程其实就是遍历所有的GC Roots ,然后将所有GC Roots 可达的对象,标记为存活的对象。
清除
: 遍历整个堆,把未标记的对象清除。
用通俗的话解释一下
标记/清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停
,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
- 劣势:
- 效率问题
需要两次遍历堆(标记一次,清除一次)效率比较低,而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲; - 空间问题(标记清除后会产⽣⼤量不连续的碎⽚)
这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象
都是随机的出现在内存的各个角落,现在把他们清除之后,内存的布局自然乱七八糟,而为了应付
这一点,JVM就不得不维持一个内存空间的空闲列表,这又是一种开销。而且在分配数组对象的时
候,寻找连续的内存空间会不太好找。
复制算法(Copying)【新生区使用的GC算法,属于轻GC】
-
为了解决标记清除算法的效率问题,“复制”收集算法出现了。它可以将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收。
-
两个幸存者区动态交替,一个为from区,一个为to区,
空的幸存者区为to区
,对象的复制方向
是:在GC后,Eden和from区
中存活的对象复制到to区
,然后清除Eden和from区,即,轻GC发生在Eden和from区。
-
原理:
HotSpot JVM 把年轻代分为了三部分:一个 Eden 区 和 2 个Survivor区(from区 和 to区)。默认比例为 8:1:1。
一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次
Minor GC后,如果仍然存活,将会被移到to区,然后清理
Eden区;对象在Survivor区中
每熬过一次Minor GC ,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到养老区中。【默认是15岁,通过-XX:MaxTenuringThreshold
设定参数)】
从第二次
Minor GC开始:在GC开始的时候,对象只会在Eden区和名为 “From” 的Survivor区,Survivor区“TO” 是空的,紧接着进行GC,Eden区中所有存活的对象都会被复制到 “To” , 而在 “From” 区中
,仍存活的对象会根据他们的年龄值来决定去向
。年龄达到一定值的对象会被移动到老年代中,没有达到阈值的对象会被复制到 “To区域”,经过这次GC后,Eden区和From区已经被清空,这个时候, “From” 和 “To” 会交换他们的角色,
也就是新的 “To“ 就是GC前的”From“ , 新的 ”From“ 就是上次GC前的 ”To“。不管怎样,都会保证名为To 的Survicor区域是空的
。 Minor GC会一直重复这样的过程。直到 To 区 被填满
, ”To “ 区被填满之后,会将所有的对象移动到老年代中。
- 好处:没有内存碎片,
- 坏处:
复制算法它的缺点也是相当明显的。
1、浪费内存空间:一半to空间(空的幸存区)永远是空的,他浪费了一半的内存,这太要命了;
2、如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。
所以从以上描述不难看出。复制算法的最佳使用场景是对象的存活率较低的时候------>即,新生区
。
注:GC在新生去发生在Eden和from区
标记整理(Mark-Compact)
-
为了解决标记清除算法的空间问题。
标记过程仍然与“标记-清除”算法的标记⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉,如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。 -
注:
标记、整理算法不仅可以弥补 标记、清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
但,时间效率较标记清除算法更差,因为标记整理有3次遍历,除了标记清除两次遍历,在标记清除中间还有整理,花费一次遍历。
分代收集算法
小总结
- 内存效率:复制算法 > 标记清除算法 > 标记整理算法 (时间复杂度)
- 内存整齐度:复制算法 = 标记整理算法 > 标记清除算法
- 内存利用率:标记整理算法 = 标记清除算法 > 复制算法
可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所
提到的三个指标,标记整理算法相对来说更平滑一些 , 但是效率上依然不尽如人意,它比复制算法多了
一个标记的阶段,又比标记清除多了一个整理内存的过程。
难道就没有一种最优算法吗?猜猜看,下面还有
答案 : 无,没有最好的算法,只有最合适的算法 。 -----------------> 分代收集算法
分代收集算法
当前虚拟机的垃圾收集都采⽤分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为⼏块。⼀般将 java 堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法
。
- 年轻代:(Young Gen)
年轻代特点是区域相对老年代较小,对象存活低。这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。 - 老年代:(Tenure Gen)
老年代的特点是区域较大,对象存活率高!这种情况,存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现。Mark阶段的开销与存活对象的数量成正比,这点来说,对于老年代,标记清除或者标记整理有一些不符,但可以通过多核多线程利用,对并发,并行的形式提标记效率。Sweep阶段的开销与所管理里区域的大小相关,但Sweep “就地处决” 的 特点,回收的过程没有对象的移动。使其相对其他有对象移动步骤的回收算法,仍然是是效率最好的,但是需要解决内存碎片的问题。
面试题
HotSpot 为什么要分为新⽣代和⽼年代?
主要是为了提升 GC 效率。上⾯提到的分代收集算法已经很好的解释了这个问题,根据上⾯的对分代收集算法的介绍回答。
【根据对象存活周期的不同将内存分为⼏块。⼀般将 java 堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法】
以上是关于JVM--Java虚拟机的主要内容,如果未能解决你的问题,请参考以下文章