复习准备秋招,内容有错的地方请各位看官评论区留言!不胜感激!
Java虚拟机基础知识
JVM内存区域
双亲委派机制
-
加载Class文件的原理
Java中的所有类,都需要由类加载器装在到JVM中才能运行,类加载器本身也是一个类,它的工作就是把Class文件从硬盘读取到内存中。
-
隐式装载
new等方式,隐式调用类加载器加载对应的类到JVM中
-
显示装载
通过class.forName(…)的方式显示的加载对应的类到JVM中
-
-
类加载器(JDK1.8)
Java类加载器是Java运行时环境的一部分,负责动态加载Java类到Java虚拟机的内存空间中。
-
启动类加载器: 用来加载Java核心类库,无法被Java程序直接调用
-
扩展类加载器: 加载Java的扩展库
-
应用程序类加载器: 根据Java应用的类路径来加载
-
用户自定义类加载器: 通过基础java.lang.ClassLoader实现
-
-
双亲委派机制概述
- 1.如果一个类加载器收到了类加载的请求:
- 2.首先判断被加载的类是否已经加载过,如果是则结束,否则会将加载任务委托给自己的父亲;
- 3.如果未加载过,它首先不会自己去加载,而是把这个请求委派给父类加载器去完成。每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中。
- 4.启动类加载器会先判断被加载的类是否已经加载过,如果是,则结束。如果不是,则进行以下操作:
- 5.启动类加载器判断能否完成加载任务,如果能,直接加载;如果不能,则将任务交给子加载器。
- 6.子类加载器也会判断能否完成加载任务,如果能则直接加载,否则会再一次将加载任务交给儿子类加载器。
不断进行上述步骤,直到最后一个类加载器,如果这个加载器仍然不能加载这个类,则抛出ClassNotFoundException。
-
双亲委派机制好处
- 1.保证了Java核心库的安全性。如果你也写了一个java.lang.String类,那么JVM只会按照上面的顺序加载jdk自带的String类,而不是你写的String类。
- 2.保证同一个类不会被加载多次。
-
缺陷
- java SPI机制
-
打破双亲委派机制
-
1.重写自定义ClassLoader的loadClass()方法
-
2.使用线程上下文类加载器
通过java.lang.Thread中的setContextClassLoader()方法设置线程的类加载器。
jdbc中有使用。 -
3.热启动/热部署
idea/tomcat等中有使用。原理是重写了ClassLoader.loadClass方法。
-
类加载机制
-
1.加载
- 将Java文件转化为二进制字节流
- 将静态结构转为方法区的运行时数据结构
- 在Java堆中生成一个代表这个类的java.long.Class对象,作为方法区这些数据的访问入口
-
2.验证
- 检查Class文件的字节流是否符合当前虚拟机的要求,是否会损害虚拟机的安全
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
-
3.准备
- 类变量分配内存,设置初始值(方法区中)
-
4.解析
- 符合引用转为直接引用
-
5.初始化
- 开始执行类中定义的Java代码
- 真正赋值
对象创建过程
-
1.类加载检查
JVM遇到一系列new 指令时,首先会去检查这个指令的参数是否能在常量值中定位到这个类的符合引用,并且检查这个符号引用代表的类是否已经被加载、解析、初始化过。如果没有,则先执行类加载过程。
-
2.分配内存
-
类加载检查通过后,JVM将为新生对象分配内存。对象所需的内存大小在类加载完成时便可确定,把一块确定大小的内存从Java堆中划分出来。
-
分配内存的方式: 指针碰撞、 空闲列表
-
内存分配的并发问题解决方法:
- CAS+失败重试
- TLAB
-
-
3.初始化零值
内存分配完成后,JVM需要将分配到的内存空间都初始化为零值(不包括对象头),保证了对象的实例字段在Java代码中不赋初值就可以直接使用。
-
4.设置对象头
JVM要对对象进行必要的设置,如这个对象是哪个类的实例,如果才能找到类的元数据信息,对象的hash值,GC年龄分代等信息,存放在对象头中。
-
5.执行init方法
执行init方法,按程序代码初始化对象。
运行时区域
堆
- Java堆是Java虚拟机所管理内存最大、被所有线程共享的一块区域,目的是用来存放对象,基本上所有的对象实例和数组都在堆上分配(不是绝对)
- 线程共享:堆存放的对象,某个线程修改了对象属性,另外一个线程从堆中获取的该对象是修改后的对象。
- 存放对象:基本上所有的对象实例和数组都要在堆上进行分配,但是随着 JIT 编译器的发展和逃逸分析技术的成熟,栈上分配、标量替换等优化技术会导致对象不一定在堆上进行分配。
- 垃圾收集:Java堆是垃圾回收器的主要操作内存区域。当前垃圾回收器都是使用的分代收集算法:Java堆还可以分为:新生代和老年代,而新生代又可以分为 Eden 空间、From Survivor 空间、To Survivor空间。
- 抛出 OutOfMemoryError 异常:Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可,实现时既可以实现成固定大小,也可以是扩展的。如果在堆中没有完成实例分配,并且堆也无法扩展,将抛出OutOfMemoryError 异常。
方法区
- 方法区(Method Area)用来存储已被Java虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 方法区也称为“永久代”,这是因为垃圾回收器对方法区的垃圾回收比较少,主要是针对常量池的回收以及对类型的卸载,回收条件比较苛刻。
- 抛出OutOfMemoryError 异常
- 运行时常量池:
- 运行时常量池(Runtime Constant Pool)用于存放编译期生成的各种字面量和符号引用,是方法区的一部分。
- Java虚拟机规范对其没有做任何细节的要求,所以不同虚拟机实现商可以按照自己的需求来实现该区域,比如在 HotSpot 虚拟机实现中,就将运行时常量池移到了堆中。
- 存放字面量、符合引用、直接引用。通常来说,该区域除了保存Class文件中描述的引用外,还会把翻译出来的直接引用也存储在运行时常量池,并且Java语言并不要求常量一定只能在编译器产生,运行期间也可能将常量放入池中。例如String.intern() 方法。
- 抛出OutOfMemoryError 异常。
虚拟机栈
- Java 每个方法执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 线程私有:随线程创建而创建,生命周期和线程保持一致。
- 由栈帧组成。
- 抛出StackOverflowError 和 OutOfMemoryError 异常:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
- 如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。
本地方法栈
- 本地方法栈执行的是 Native 方法,本地方法栈也会抛出抛出 StackOverflowError 和 OutOfMemoryError 异常。
- 虚拟机规范没有对本地方法栈中的方法使用语言、使用方式和数据结构强制规定,因此具体的虚拟机可以自由实现它。
程序计数器
- 作用:程序计数器(Program Conputer Register)是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器,在虚拟机的概念模型里,字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 线程私有:Java虚拟机支持多线程,是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任一确定的时刻,一个处理器只会执行一条线程中的指令,因此为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器。因此线程启动时,JVM 会为每个线程分配一个PC寄存器(Program Conter,也称程序计数器)。
- 记录当前字节码指令执行地址:如果当前线程执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,则这个计数器值为空(Undefined)。
- 不抛OutOfMemeryError异常:程序计数器的空间大小不会随着程序执行而改变,始终只是保存一个 returnAdress 类型的数据或者一个与平台相关的本地指针的值。所以该区域是Java运行时内存区域中唯一一个Java虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
JVM规范定义的运行时数据区
HotSpot JDK1.8定义的运行时数据区
执行引擎
任务
执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令
工作过程
- 所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
- 方法在执行的过程中,执行引擎有可能会通过存储在局部变量表(栈)中的对象引用准确定位到存储在Java堆区中的对象实例信息。
- 方法在执行过程中,可以通过对象头(堆)中的元数据指针,定位到目标对象的类型信息(方法区)。
解释器
解释执行:JVM在执行时,首先会逐条读取前端编译后得到的中间表达形式的指令来执行。这就是解释执行的过程。
Java即时编译(JIT)
-
编译执行:当某一个方法的调用次数达到即时编译定义的阈值时,就会触发即时编译。这时即时编译会将中间表达式进行优化,并生成这个方法的机器码。后面再调用这个方法时,就会直接调用机器码执行。
-
触发条件:
- 方法的调用次数
- 循环回边的执行次数: JVM在调用一个方法时,就会在计数器上加1,如果方法里面有循环体,每次循环,计数器也会加1。在不启用分层编译时,当某一方法的计数器达到由参数:-XX:CompileThreshold 指定的值时(C1为1500,C2为10000),就会触发即时编译。
- code cache:热点代码的暂存区,经过即时编译的代码会放在这,位于堆外内存。
-
HotSpot编译器
- C1编译器:对应参数-client,对应于执行时间较短,对启动性能有要求的程序可以选择C1编译器。
- C2编译器:对应参数 -server,对峰值性能有要求的程序,可以选择C2编译器。
逃逸分析
-
概述:在编译期间,JIT会对代码做很多优化。其中由一部分优化的目的就是减少内存堆分配压力。其中一种技术叫:逃逸分析。
逃逸分析:一种可以有效减少程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,HotSpot编译器可以分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。 -
基本行为:分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用。例如作为调用参数传递到其他地方中(方法逃逸)。
-
用途:
- 1.如果一个对象被发现只能在一个线程中被访问到,那么对于这个对象的操作可以不考虑同步。
- 2.将堆分配转为栈分配:如果一个对象在子程序中被分配,并且指向该对象的指针永远不会逃逸,则对象可能是栈分配的候选,而不是堆分配。
本地方法接口JNI
概述
- 一个 Native Method Interface是一个 Java 调用非 Java 代码的接囗
作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序。
本地方法
本地方法是一个非Java的方法,它的具体实现是非Java代码的实现。
用处
- 1.与Java环境交互:有时java应用需要与java外面的环境交互,这是本地方法存在的主要原因。
- 2.与操作系统交互:JVM支持着java语言本身和运行库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层操作系统的支持。通过使用本地方法,我们得以用java实现了jre的与底层系统的交互,甚至jvm的一些部分就是用C写的。
- 3.Sun’s Java:Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用java实现的,它也通过一些本地方法与外界交互。
本地方法库
JMM内存模型
概念
JMM是一种符合内存模型规范的,屏蔽了各种硬件和操作系统访问差异的,保证了Java程序在各种平台下堆内存的访问都能保证效果一致的机制及规范。
目的
作用于工作内存和主存之间数据同步过程,规定了如何做数据同步以及什么时候做数据同步。
内存屏障
一组CPU指令,用来实现对内存操作的顺序限制。
-
LoadLoad: 确保Load1数据的装载先于Load2及后续所有装载指令的装载。
-
StoreStore: 确实Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储。
-
LoadStore: 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存。
-
StoreLoad:
- 全能型屏障,同时具有其他三个屏障的效果。
- 确保Store1数据对其他处理器可见(刷新到内存)先于Load2及所有后续装载指令的装载。
StoreLoad屏障会使该屏障之前的所有内存访问指令完成之后,才执行该屏障之后的内存访问指令。
happens - before
两个操作之间有happens - before 关系,并不意味着前一个操作必须要在后一个操作之前执行。
happens - before 仅要求前一个操作的执行结果对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
指令重排
编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
数据依赖性
只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
- 写后读
- 写后写
- 读后写
as - if -serial
不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime、处理器都必须遵守 as - if - serial语义。
程序顺序规则
- happens - before
- as - if - serial
多线程情况
多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
GC机制
把内存中不再使用的对象清理出来。
什么对象需要回收?
-
引用计数法: 每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1。计数为0时可以回收。
-
可达性分析: 从GCRoots开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GCRoots没有任何引用链时,则说明该对象是不可用的。
GCRoots:
- 虚拟机栈中引用的对象
- 方法区中静态属性实体引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
GC的区域
-
堆
根据对象存活的周期不同,划分为新生代和老年代。
-
新生代: 新生成的对象优先放在新生代中,新生代中对象存活率很低,常规应用进行一次GC一般可以回收70%-95%,回收效率很高。
- Eden
- FromSurvivor
- ToSurvior
-
老年代: 在新生代中经历了多次GC后仍然存活的对象会进入到老年代中,老年代中的对象生命周期较长,存活率高。在老年代中GC的频率相对较低,且回收的速度慢。
-
-
方法区(永久代)/元数据空间HotSpot-JDK1.8
什么时候回收?
-
程序调用System.gc
-
系统自身决定GC的时机:
-
young GC: 当新生代中的eden区分配满的时候触发,young GC 中有部分存活对象会晋升到老年代,老年代的占用量会有所提高
-
full GC: 当准备要进行一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前老年代剩余的空间大,则不会触发young GC,而是转为触发full GC
-
怎么回收?
GC算法
-
复制算法
-
标记清除算法
过程:- 标记阶段:标记所有从根节点开始的可达对象,未被标记的对象就是未被引用的垃圾对象
- 清除阶段:清楚所有未标记的对象
不足:
- 标记和清除效率都不高
- 标记、清除后会产生大量不连续的内存碎片
-
标记整理算法
和标记清除算法类似,但会将所有的存活对象移动到一端,并对不存活对象进行回收,不会产生内存碎片
垃圾收集器
-
新生代:
-
Serial收集器: 新生代单线程串行收集器
-
ParNew收集器: 新生代并行收集器,Serial的多线程版本,在多核CPU时表现比Serial好
-
Parallel Scavenge收集器: 新生代并行收集器,追求吞吐量高
-
-
老年代:
-
Serial Old收集器: 老年代单线程串行收集器
-
Parallel Old收集器: 老年代并行收集器,追求吞吐量
-
CMS(Concurrent Mark Sweep)收集器: 低时延收集器——常用于Web应用
-
初始标记阶段
一段小停顿,用于确定GCRoots
-
并发标记阶段
标记GCRoots可达的所有存活对象,这个阶段应用程序同时也在运行。该阶段结束后,并不能标记处所有的存活对象。
为了标记出所有的存活对象,需要再次停顿应用程序。 -
再次标记阶段
遍历在并发标记阶段应用程序修改的对象,由于这次停顿比初始标记要长得多,所以会使用多线程并行来增加效率。
-
并发清理阶段
再次标记阶段结束后,所有的存活对象已被标记。执行并发清理阶段,就地回收垃圾对象所占空间
-
特点
1.低延时
2.不对收集后的空闲空间进行压缩,因此分配内存要使用空闲列表法
3.CMS不能等老年代满了才开始收集。否则CMS将退化成更加耗时的标记整理法。CMS需要统计之前每次垃圾回收的时间和老年代空间被消耗的速度
4.如果老年代被消耗了预设占用率,也会触发一次GC。这个占用率可以自行设定,JDK1.8默认是 92%
5.如果老年代空间不足以容纳新生代垃圾回收晋升上来的对象,那么就触发 concurrent mode failure,退化成 full GC。清除老年代中所有无效对象,单线程操作,耗时。
6.如果由于碎片化问题导致对象晋升失败,也会触发full GC,此时耗时更严重,需要对整个堆进行压缩,之后新生代彻底为空。 -
优缺点
优点: 节省时间
缺点:- 1.CMS需要比其他收集器更大的堆内存。在并发收集阶段,程序还在运行,所以需要留足够的空间给应用程序
- 2.可能产生活动垃圾。并发标记阶段存活的对象可能立马称为垃圾,而这部分由于已经被标记为存活对象,只有等到下次老年代收集时才会被清理。
-
-
G1收集器
G1收集器将整个堆划分为一个个大小相等的小块(region),每一块的内存是连续的,每个块会充当Eden,Survivor,Old三种角色,但不是固定的。
- 目标:在达到可控的停顿时间(通过-XX:MaxGCPauseMillis=200 指定期望的停顿时间)的基础上,尽可能提高吞吐量
四种操作
-
新生代收集:
- 1.将存活的对象复制到Survivor区,或晋升到老年代
- 2.为了下一次young GC,需要调整Eden区和Survivor区的大小(基于历史young GC 统计信息和用户定义的停顿时间目标)
-
Old GC/并发标记周期:
-
初始标记: stop-the-world,它伴随着一次普通的 Young GC 发生,然后对 Survivor 区(root region)进行标记,因为该区可能存在对老年代的引用。
-
扫描根引用区: 因为先进行了一次 Young GC,所以当前新生代只有 Survivor 区有存活对象,它被称为根引用区。扫描 Survivor 到老年代的引用,该阶段必须在下一次 Young GC 发生前结束。
这个阶段不能发生年轻代收集,如果中途 Eden 区真的满了,也要等待这个阶段结束才能进行 Young GC。 -
并发标记: 寻找整个堆的存活对象,该阶段可以被 Young GC 中断。 这个阶段是并发执行的,中间可以发生多次 Young GC,Young GC 会中断标记过程
-
再次标记: stop-the-world,完成最后的存活对象标记。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法。 ( Oracle 的资料显示,这个阶段会回收完全空闲的区块。)
-
清理: 清理阶段真正回收的内存很少。
清理之后G1 的一个并发周期就算结束了,其实就是主要完成了垃圾定位的工作,定位出了哪些分区是垃圾最多的。因为整堆一般比较大,所以这个周期应该会比较长,中间可能会被多次 stop-the-world 的 Young GC 打断。
-
-
混合式垃圾收集:
- 并发周期结束后是混合垃圾回收周期,不仅进行年轻代垃圾收集,而且回收之前标记出来的老年代的垃圾最多的部分区块。
- 混合垃圾回收周期会持续进行,直到几乎所有的被标记出来的分区(垃圾占比大的分区)都得到回收,然后恢复到常规的年轻代垃圾收集,最终再次启动并发周期。
-
必要时的full GC:
- 并发模式失败:G1 并发标记期间,如果在标记结束前,老年代被填满,G1 会放弃标记。执行full GC
- 晋升失败:并发周期结束后,是混合垃圾回收周期,伴随着年轻代垃圾收集,进行清理老年代空间,如果这个时候清理的速度小于消耗的速度,导致老年代不够用,那么会发生晋升失败。进行full GC
- 疏散失败:年轻代垃圾收集的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象。执行full GC。
- 大对象分配失败:尽量不创建那种大于一个区块的对象
G1 参数配置和调优
-
可调优的方向——G1 调优的目标是尽量避免出现 Full GC,其实就是给老年代足够的空间,或相对更多的空间。有以下几点可进行调整的方向:
- 增加堆大小,或调整老年代和年轻代的比例
- 增加并发周期的线程数量,其实就是为了加快并发周期快点结束
- 让并发周期尽早开始,这个是通过设置堆使用占比来调整的(默认 45%)
- 在混合垃圾回收周期中回收更多的老年代区块
-
参数配置
垃圾收集器性能评估指标
- 吞吐量
- 垃圾收集开销
- 停顿时间
- 频次
- 空间
- 及时性
三种GC现象
minor GC
- 新生代
major GC
- 老年代
full GC
- 新生代+老年代