JAVA—JVM详解
一、JVM
1、什么是JVM
- JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
- 引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
- 通过JVM,Java实现了平台无关性,Java语言在不同平台运行时不需要重新编译,只需要在该平台上部署JVM就可以了。因而能实现一次编译多处运行。
2、JRE和JDK
- JRE:Java Runtime Environment,也就是JVM的运行平台,联系平时用的虚拟机,大概可以理解成JRE=虚拟机平台+虚拟机本体(JVM)。类似于你电脑上的VMWare+适用于VMWare的Ubuntu虚拟机。这样我们也就明白了JVM到底是个什么。
- JDK:Java Develop Kit,Java的开发工具包,JDK本体也是Java程序,因此运行依赖于JRE,由于需要保持JDK的独立性与完整性,JDK的安装目录下通常也附有JRE。目前Oracle提供的Windows下的JDK安装工具会同时安装一个正常的JRE和隶属于JDK目录下的JRE。
3、JVM位置
4、JVM体系结构
- 简图:
- 详图:
二、JVM详解
5、ClassLoader(类加载器)
(1)、类加载的机制的层次结构
每个编写的".java"拓展名类文件都存储着需要执行的程序逻辑,这些".java"文件经过Java编译器编译成拓展名为".class"的文件,".class"文件中保存着Java代码经转换后的虚拟机指令,当需要使用某个类时,虚拟机将会加载它的".class"文件,并创建对应的class对象,将class文件加载到虚拟机的内存,这个过程称为类加载,这里我们需要了解一下类加载的过程,如下:
在虚拟机提供了3种类加载器,引导(Bootstrap)类加载器、扩展(Extension)类加载器、系统(System)类加载器(也称应用类加载器),下面分别介绍
-
启动(Bootstrap)类加载器
启动类加载器主要加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分,它负责将 <JAVA_HOME>/lib路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中,注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)。 -
扩展(Extension)类加载器
扩展类加载器是指Sun公司(已被Oracle收购)实现的sun.misc.Launcher$ExtClassLoader类,由Java语言实现的,是Launcher的静态内部类,它负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器。 -
系统(System)类加载器
也称应用程序加载器是指 Sun公司实现的sun.misc.Launcher$AppClassLoader。它负责加载系统类路径java -classpath或-D java.class.path 指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。 -
全流程简图
//代码详解
public class TestClassLoader {
public static void main(String[] args) {
Person person_01 = new Person();
Person person_02 = new Person();
Person person_03 = new Person();
//发现person_01,person_02,person_03的hashCode一致,代表这三个实例化对象隶属于一个Class,即Person
System.out.println(person_01.hashCode());
System.out.println(person_02.hashCode());
System.out.println(person_03.hashCode());
//Person实例化对象person_01通过getClass()方法得到Class对象Person
Class Person = person_01.getClass();
//Person通过getClassLoader()方法得到系统类加载器
ClassLoader myClassLoader = Person.getClassLoader();
System.out.println(myClassLoader.hashCode());
//加载器对象myClassLoader通过getParent()方法得到拓展类加载器
ClassLoader myParentClassLoader = myClassLoader.getParent();
System.out.println(myParentClassLoader.hashCode());
//加载器对象myGPClassLoader通过getParent()方法得到引导类加载器
ClassLoader myGPClassLoader = myParentClassLoader.getParent();
System.out.println(myGPClassLoader.hashCode()); //发现报错,无法通过方法获取引导类加载器
}
}
class Person{}
(2)、双亲委派机制
- 双亲委派模式要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,请注意双亲委派模式中的父子关系并非通常所说的类继承关系,而是采用组合关系来复用父类加载器的相关代码,类加载器间的关系如下:
- 双亲委派机制工作原理为如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载。
- 双亲委派机制优点:
- 因为双亲委派是向上委托加载的,所以它可以确保类只被加载一次, 避免重复加载 。
- Java的核心API都是通过引导类加载器进行加载的,如果别人通过定义同样路径的类比如java.lang.Integer,类加载器通过向上委托,两个Integer,那么最终被加载的应该是jdk的Integer类,而并非我们自定义的,这样就 避免了我们恶意篡改核心包的风险 。
6、沙箱安全机制
- 沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?——CPU、内存、文件系统、网络。
- 组成沙箱的基本组件:
- 1.字节码校验器(bytecode verifier) :确保Java类文件遵循Java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。
- 2.类裝载器(class loader) :其中类装载器在3个方面对Java沙箱起作用
防止恶意代码去干涉善意的代码;
守护了被信任的类库边界;
将代码归入保护域,确定了代码可以进行哪些操作。
7、本地方法栈(Native)
- 程序中使用:
private native void start0();
1.凡是带了native关键字的,说明java的作用范围达不到了,回去调用底层c语言的库!
2.会进入本地方法栈,然后去调用本地方法接口将native方法引入执行 - 本地方法栈(Native Method Stack)
内存区域中专门开辟了一块标记区域: Native Method Stack,负责登记native方法,在执行引擎( Execution Engine )执行的时候通过本地方法接口(JNI)加载本地方法库中的方法。 - 本地方法接口(JNI)
本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序, Java在诞生的时候是C/C++横行的时候,想要立足,必须有调用C、C++的程序,然后在内存区域中专门开辟了一块标记区域: Native Method Stack,负责登记native方法,在执行引擎( Execution Engine )执行的时候通过本地方法接口(JNI)加载本地方法库中的方法。
8、PC程序计数器(了解)
程序计数器: Program Counter Register
- 每个线程都有一个程序计数器,是线程私有的,就是一个指针, 指向方法区中的方法字节码(用来存储指向像一条指令的地址, 也即将要执行的指令代码),在执行引擎读取下一条指令, 是一个非常小的内存空间,几乎可以忽略不计。
- 为什么需要程序计数器?
- 记录要执行的代码位置,防止线程切换重新执行字节码执行引擎修改程序计数器的值
9、方法区(Method Area)
- 方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间。
- 静态变量(static)、常量(final)、类信息(构造方法、接口定义)(Class)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关
10、栈
- 栈:后进先出,每个线程都有自己的栈,栈内存主管程序的运行,生命周期和线程同步,线程结束,栈内存也就是释放。
- 对于栈来说,不存在垃圾回收问题,一旦线程结束,栈就结束.
- 栈内存中运行:8大基本类型+对象引用+实例的方法.
- 栈运行原理:栈桢
- 栈满:StackOverflowError
- 栈细分4部分
例:int a=7;
- 局部变量表:存放局部变量(a)
- 操作数栈:存放操作数(7)
- 动态链接:将符号引用转成直接引用(符号引用就是你知道调用了谁,直接引用就是你拿到可要调用的方法的地址)
- 方法出口:方法结束
11、堆(重点)
-
一个JVM只有一个堆内存,堆内存的大小是可以调节的,类加载器读取类文件后,一般会把类,方法,常量,变量,我们所有引用类型的真实对象,放入堆中。
-
堆内存细分为三个区域:
- 新生区(伊甸园区):Young/New
- 养老区old
- 永久区Perm
新生区:类的诞生,成长和死亡的地方
分为:
- 伊甸园区:所有对象都在伊甸园区new出来
- 幸存0区和幸存1区:轻GC之后存下来的
老年区(养老区):多次轻GC存活下来的对象放在老年区
- 真理:经过研究,99%的对象都是临时对象!
永久区
注意:
元空间:逻辑上存在,物理上不存在 ,因为:存储在本地磁盘内,不占用虚拟机内存
默认情况下,JVM使用的最大内存为电脑总内存的四分之一,JVM使用的初始化内存为电脑总内存的六十四分之一.
总结:
- 栈:基本类型的变量,对象的引用变量,实例对象的方法
- 堆:存放由new创建的对象和数组
- 方法区:Class对象,static变量,常量池(常量)
三、JVM调优
12、使用JPofiler工具分析OOM原因
下载地址:https://www.ej-technologies.com/download/jprofiler/version_92
13、常见JVM调优参数
四、垃圾回收(GC)
- 无法手动垃圾回收,只能手动提醒,等待JVM自动回收
- GC的作用区在堆(Heap)和方法区中
- JVM进行GC时,并不是统一对这三区域(新生区,幸存区,老年区)统一回收,回收都是新生代
- 轻GC(普通GC)只针对于新生区,偶尔作用幸存区(在新生区满的情况下)
- 重GC(全局GC)全局释放内存
14、常见垃圾回收算法
-
1.引用计数算法
原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只用收集计数为 0 的对象。此算法最致命的是无法处理循环引用的问题。 -
2.复制算法
此算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中,同时回收未使用的对象。此算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理。
优点:不会出现碎片化问题
缺点:需要两倍内存空间,浪费
-
3.标记-清除算法
此算法执行分两阶段。第一阶段从引用根节点开始标记所用存活的对象,第二阶段遍历整个堆,把未标记的对象清除。
优点:不会浪费内存空间
缺点:此算法需要暂停整个应用,同时,会产生内存碎片
-
4.标记-压缩算法
此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有存活的对象,第二阶段遍历整个堆,清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。
此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。
-
总结:
- 内存效率(时间复杂度):复制算法>标记清除算法>标记压缩算法
- 内存效率整齐度:复制算法=标记压缩算法>标记清除算法
- 内存利用率:标记清除算法=标记压缩算法>复制算法
15、分代回收策略
- 1.绝大多数刚刚被创建的对象会存放在Eden区
- 2.当Eden区第一次满的时候,会触发MinorGC(轻GC)。首先将Eden区的垃圾对象回收清除,并将存活的对象复制到S0,此时S1是空的。
- 3.下一次Eden区满时,再执行一次垃圾回收,此次会将Eden和S0区中所有垃圾对象清除,并将存活对象复制到S1,此时S0变为空。
- 4.如此反复在S0和S1之间切换几次(默认15次)之后,还存活的对象将他们转移到老年代中。
- 5.当老年代满了时会触发FullGC(全GC)
MinorGC
- 使用的算法是复制算法
- 年轻代堆空间紧张时会被触发
- 相对于全收集而言,收集间隔较短
FullGC
- 使用的算法一般是标记压缩算法
- 当老年代堆空间满了,会触发全收集操作
- 可以使用 System.gc()方法来显式的启动全收集
- 全收集非常耗时
16、垃圾收集器
垃圾回收器的常规匹配:
-
1.串行收集器(Serial)
Serial 收集器是 Hotspot 运行在 Client 模式下的默认新生代收集器, 它的特点是:单线程收集, 但它却简单而高效
-
2.并行收集器(ParNew)
ParNew 收集器其实是前面 Serial 的多线程版本
-
3.Parallel Scavenge 收集器
与 ParNew 类似, Parallel Scavenge 也是使用复制算法, 也是并行多线程收集器. 但与其
他收集器关注尽可能缩短垃圾收集时间不同, Parallel Scavenge 更关注系统吞吐量:
系统吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间) -
4.Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本, 同样是单线程收集器,使用“标记-整理”算法 -
5.Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本, 使用多线程和“标记-整理”算
法, 吞吐量优先 -
6.CMS 收集器(Concurrent Mark Sweep)
CMS是一种以获取最短回收停顿时间为目标的收集器(CMS又称多并发低暂停的收集器),
基于”标记-清除”算法实现, 整个 GC 过程分为以下 4 个步骤:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark: GC Roots Tracing 过程)
重新标记(CMS remark)
并发清除(CMS concurrent sweep: 已死对象将会就地释放, 注意:此处没有压缩) -
7.G1 收集器
G1将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
为什么要垃圾回收时要设计STW(stop the world)?
- 如果不设计STW,可能在垃圾回收时用户线程就执行完了,堆中的对象都失去了引用,全部变成了垃圾,索性就设计了STW,快速做完垃圾回收,再恢复用户线程运行。
五、JMM(java内存模型)
JMM(java内存模型)Java Memory Model,本身是一个抽象的概念,不是真实存在的,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程读/写共享变量的副本。
JMM内存模型三大特性
- 1、原子性
使用 synchronized 互斥锁来保证操作的原子性 - 2、可见性:
volatile,会强制将该变量自己和当时其他变量的状态都刷出缓存。
synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。 - 3、有序性
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 ->最终执行的命令。
重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
处理器在进行重排时必须考虑数据的依赖性,多线程环境线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。