背景
JDK/JRE/JVM三者的关系
JDK:Java Development Kit Java开发工具包
JRE:Java Runtime Environment Java运行时环境
JVM:Java Virtual Machine Java虚拟机
说明:不同的平台(操作系统不同)开发了不同的JDK(机器码不同)
Java编译运行的全过程
1、Java代码编译:是由Java源码编译器来完成,生成字节码文件.class。
- 编译后的字节码文件格式主要分为两部分:常量池和方法字节码。常量池记录的是代码出现过的所有token(类名,成员变量名等)以及符号引用(方法引用,成员变量引用等);方法字节码放的是类中各个方法的字节码。
2、Java字节码的执行:是由JVM执行引擎来完成,包括类的加载和类的执行。
- JVM在程序第一次主动使用类的时候,才会去加载该类。也就是说,JVM并不是在一开始就把一个程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次。
JVM架构
针对Hotspot JVM
一、概述
1.1 主要组成
类加载器(ClassLoader)
运行时数据区(Runtime Data Area)
执行引擎(Execution Engine)
本地库接口(Native Interface)
1.2 作用
首先通过类加载器
会把 Java 代码转换
成字节码,运行时数据区
再把字节码加载
到内存中,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎
,将字节码翻译
成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口
来实现整个程序的功能。
二、详解
1、类加载器子系统 class loader subsystem
作用:负责class文件的加载
类加载过程:
加载:根据查找路径找到相应的 class 文件然后导入;
检查:检查加载的 class 文件的正确性;
准备:给类中的静态变量分配内存空间;
解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;
初始化:对静态变量和静态代码块执行初始化工作。
分3个阶段:
理解:将类从磁盘以二进制字节流的形式读取到内存的方法区(此阶段是将静态存储结构转化为运行时数据结构,作为类的数据的访问入口)
理解:
验证
:验证类的正确性(符合JVM要求);
准备
:分配类变量的内存并赋默认值(final修饰的类变量默认值在编译时就分配了,这里会显式初始化;实例变量此时不做处理,随类的实例化分配内存在堆);(补充:这里还会创建并初始化虚方法表)
解析
:符号引用转换为直接引用(即动态绑定,栈中动态链接的作用是完成动态绑定,注意:解析往往在初始化阶段之后执行,这是为了支持 Java 语言的运行时绑定,即动态链接或晚期绑定)
理解:将类变量的赋值和静态代码块的语句合并(通过类构造器方法clinit())
此阶段完成后,类信息加载完毕,JVM找到主函数入口,开始执行main函数。此过程发生在运行时数据区(内存)中
1.1 双亲委派机制
原理:当一个类加载器收到类加载请求时,会委托给父类的加载器去执行;层层委托,到达最顶端的父类加载器,加载完成后成功返回,如果无法加载,子加载器才会去加载
优点:避免类的重复加载;防止核心API被篡改
拓展:这里引申出一个概念
在JVM中表示2个class对象为同一个类有2个必要条件:
1、包括包名的完整类名一致
2、加载该类的加载器实例对象相同
加载器的上下结构:
- 引导类加载器(Bootstrap 又称启动类加载器):只负责加载包名为java、javax、sun等开头的类,用来加载
Java核心类库
,无法被Java程序直接引用 - 拓展类加载器:加载jre/lib/ext(扩展目录),用来加载Java的拓展库,Java的虚拟机实现会提供一个
扩展库目录
,该类加载器在扩展库目录中查找并加载Java类 - 系统类加载器(又称应用程序类加载器):程序默认的加载器,根据Java的类路径来加载类,
Java应用的类
都是通过它来加载的
1.2 类的主动使用和被动使用
类的被动使用不会导致类的初始化
2、 运行时数据区
组成
程序计数器(Program Counter Register)
Java 虚拟机栈(Java Virtual Machine Stacks)
本地方法栈(Native Method Stack)
Java 堆(Java Heap)
方法区(Methed Area)
2.1 程序计数器(PC寄存器)
作用:存储指向下一条操作指令的指令地址,即将要执行的操作指令地址
示意图理解:
这里存储的只是前面的指令地址,而具体的指令地址和操作指令的对应表,是随着类的加载存储在方法区的类信息的方法信息
思考:为什么需要程序计数器
为执行引擎和CPU记录当前线程执行的位置,因为CPU是在底层并发(快速切换)的执行多个线程任务的
思考:PC寄存器为什么被设定为每个线程私有
为了准确记录各个线程正在执行的当前字节码指令地址,各线程独立计算,互不干扰
2.2 Java虚拟机栈
方法执行过程
一个线程分配一个栈,
每执行一个线程的一个方法时,在对应的线程栈中分配一块区域,代表一个栈帧
栈方法中存放有局部变量表、操作数栈、动态链接(指向运行时常量池的方法引用)、方法出口
2.2.1 局部变量表(LV)
首先了解一下变量的分类
变量的分类:
- 按数据类型,分为基本数据类型和引用数据类型,
- 按类中声明的位置,分为成员变量(包括类变量(static修饰)和实例变量)和局部变量
对变量的理解:
类变量在类加载时的链接阶段默认赋值,在初始化阶段显式赋值(存放在方法区)
实例变量随着对象的创建,在堆空间分配实例变量空间,并进行默认赋值
而局部变量在使用前必须进行显式赋值,否则编译不通过(原因:由于方法加载到栈时,局部变量维护在局部变量表里,而局部变量表是一个数组,必须有值)
2.2.2 操作数栈(OS)
首先了解栈的底层数据结构可以是数组或链表
操作数栈:是在方法执行过程中,根据字节码指令,往栈(数组)中写入或提取数据,即入栈和出栈(借助于执行引擎)
思考:++i 和i++的区别
2.2.3 动态链接(DL 指向运行时常量池的方法引用)
作用:将常量池的符号引用转换为调用方法的直接引用
思考:为什么需要常量池
提供一些符号和常量,便于指令的识别(共用,节省空间)
对调用方法的理解
绑定:一个字段、方法或类的符号引用被替换为直接引用的过程(对于方法而言,这个过程是方法调用和方法定义的关联,即当在 Java 中调用方法时,程序控件将绑定到定义该方法的内存地址)
早期绑定:发生在编译时,被调用的目标方法在编译期可知(对应静态链接)
晚期绑定:发生在运行时,被调用的目标方法在编译期无法确定,只能在运行期根据实际类型绑定相关的方法(对应动态链接)
这里可以理解的是,类加载的链接阶段的解析过程:符号引用转换为直接引用,特指的是动态绑定
非虚方法:如果方法在编译期就确定了具体的调用版本,就称为非虚方法(不可重写,也就是说调用方法的类是确定的,就是当前这个类),包括静态方法、私有方法、final方法、实例构造器、父类方法,其他方法为虚方法
思考:为什么不可重写的方法可以在编译期就确定了具体的调用版本呢?
这里就需要理解Java语言多态性的概念
首先,了解子类对象的多态性的使用前提:一是类的继承关系,二是方法的重写
其次,理解方法重写的本质:
- 找到操作数栈顶的元素test所执行对象的实际类型,记为T(理解:当调用一个对象的方法时test.opt(),首先将对象引用test压入操作数栈,然后根据字节码指令invokevirtual去寻找这个对象的实际类型T)
- 如果在常量池中找到类型T的这个方法,进行访问权限校验,通过校验->返回这个方法的直接引用,不通过->抛异常IllegalAccessError
- 如果在常量池中的虚方法表中没有找到类型T的这个方法,则从下往上对T的各个父类进行查找及校验,依旧没找到->抛异常AbstractMethodError
理解:当发生方法重写时,由于子类可以声明为父类的对象,一个类的对象调用一个方法时,我们并不确定其调用的是子类的方法还是父类的方法,也就是不能确定当前类的实际类型。而不可重写的方法则没有这种情况。
问题:涉及方法重写时,每次从子类向父类中查找方法,性能低
解决:使用索引表来替代查找–虚方法表(在类加载的链接阶段创建并初始化)
虚方法表就是将虚方法和其待执行的实现类的方法入口对应起来(每个类中有一个虚方法表,存放方法的实际入口)
2.2.4 方法出口(方法返回地址)
存放调用该方法的程序计数器的值(程序计数器:存储指向下一条指令的地址,即将要执行的指令代码)
理解:例如当前正在执行A方法,当A方法中执行到调用B方法时,开始执行B方法,在对应的线程栈中分配一块区域,代表一个栈帧,程序计数器的值(也就是A方法中调用B方法后要执行的下一条语句的指令地址)就作为方法返回地址存储在这个栈帧中,当B方法执行结束,由执行引擎去执行方法返回地址,即开始继续执行A方法的下一条指令
2.3 本地方法栈
本地方法(native):一个Java调用非Java代码的接口
本地方法栈:管理本地方法的调用(虚拟机栈管理Java方法的调用)
2.4 Java堆
虚拟机中内存最大的一块。
作用:存放对象实例和数组
一个JVM实例对应一个堆空间,所有线程共享
(运行一个实例就是一个进程,一个进程分配一个堆,进程中所有线程共享堆)
例外:TLAB(Thread Local Allocation Buffer):每个线程分配一个私有的缓存区域
背景:堆中对象分配过程中,由于对象实例的创建非常频繁,在并发环境下从堆划分内存空间是线程不安全的,为避免多个线程操作同一个地址
,需要加锁,但会影响分配速度,因此出现TLAB(私有缓存区域,多线程可同时分配内存,而不会操作到同一地址,无需加锁,可快速分配)
2.4.1 堆分区概念变化:
这里主要学习Java8(JDK1.8)
2.4.2 堆参数设置
1、默认堆大小:
初始内存:物理电脑内存大小/64
最大内存:物理电脑内存大小/4
2、设置堆大小:
建议设置-Xms与-Xmx相同(理解:GC后,当初始堆内存不够时,需要扩容,当扩容内存空闲时,需要释放,频繁扩容释放(调整堆内存大小)造成系统压力)
-Xmx1800M
# 设置堆空间(年轻代+老年代)的最大内存为1800M
-Xms1800M
# 设置堆空间(年轻代+老年代)的初始内存为1800M
# -X jvm的运行参数;ms memory start
3、设置新生代和老年代的比例(默认2)
-XX:NewRatio=2(老年代占2,新生代占1)
4、设置新生代中Eden和2个Survivor区的比例
-XX:SurvivorRatio=8(Eden占8,Survivor0占1,Survivor1占1)
查看设置参数
方式一:
jps
jstat -gc 进程ID
方式二:
-XX:+PrintGCDetails
对应IDEA参数设置位置:
或者
2.4.3 对象分配和垃圾回收的过程
理解:
1、几乎所有的对象都是在Eden区new出来的(理解:几乎所有,如果对象大到在Eden区放不下,可能直接放在老年代),绝大部分对象的销毁都在新生代进行(80%的Java对象朝生夕死)
2、当Eden区满时,触发第1次YGC,YGC根据可达性分析算法,判断可回收的对象和不可回收的对象,接着回收可回收的对象,处理不可回收的对象。不可回收的对象被复制到其中一个空的Survivor区(To区),被复制的对象内部维护一个age计数器(1),此时另一个空的Survivor区成为To区,Eden区被清空可供使用
3、当Eden区第2次满时,触发第2次YGC,YGC对Eden区和不为空的Survivor区进行回收,Eden区不可回收的对象被复制到To区(即空的Survivor区)(age计数器(1)),不为空的Survivor区不可回收的对象也被复制到To区(age计数器(2)),此时Eden区和其中一个Survivor区(成为To区)被清空可供使用
4、当不为空的Survivor区的对象age计数器达到阈值15时,会被转移到老年区
5、当Eden区的对象复制到Survivor区时,Survivor区(To区)内存不足,会直接存放到老年区
特殊情况:
总结:
幸存者S0区和S1区:复制后有交换,谁空谁为To区
垃圾回收:频繁在新生区收集,很少在老年区收集,几乎不在永久区/元空间收集
堆和栈的对比:
- 功能方面:堆是用来存放对象的,栈是用来执行程序的。(在方法中定义的基本类型的变量和对象的引用变量在函数的栈中分配。堆存放new的对象和数组)
- 共享性:堆是线程共享的,栈是线程私有的。
- 空间大小:堆大小远远大于栈。
所有的对象都在堆分配吗
不一定,在编译期间,JIT会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。
当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
2.5 方法区
作用:存放类信息、常量、静态变量、即时编译器编译后的代码
jdk1.8:不存在方法区,元数据区:加载的类信息;堆:运行时常量池
2.5.1 交互关系图:
理解:
- 方法执行时,会在对应线程栈中开辟一个栈帧, 栈方法中存放有局部变量表、操作数栈、动态链接(指向运行时常量池的方法引用)、方法出口
- 在方法中new对象时,会在堆中开辟一块内存,存放对象实例数据;
- 栈方法的局部变量表中存放对象引用,指向堆中的对象实例地址;
- 堆中的对象实例数据中存有对象类型数据的指针,指向类加载到方法区的对象类型数据
2.5.2 方法区概念变化
JDK1.8前,方法区称为永久代(针对Hotspot JVM);实际上方法区和永久代并不等价,有的JVM不存在永久代的概念
JDK1.8后,元空间(本地内存)取代永久代(JVM内存)
思考:方法区、永久代、元空间是什么关系
类似 Java 中接口和接口实现类的关系,
方法区是接口,永久代和元空间是接口的实现类,
也就是说永久代和元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。
2.5.3 存储内容
类信息、常量、静态变量、即时编译器编译后的代码
2.5.3.1 类信息
反汇编.class文件:
思考:
1、静态方法为什么没有this对象
static方法一般称作静态方法,由于静态方法不依赖于任何对象就可以进行访问,因此对于静态方法来说,是没有this的,因为它不依附于任何对象,既然都没有对象,就谈不上this了。 并且由于这个特性,在静态方法中不能访问类的非静态成员变量和非静态成员方法,因为非静态成员方法/变量都是必须依赖具体的对象才能够被调用。
2、static 和 final 修饰的变量在编译时的区别
final修饰的静态变量在编译时就进行了赋值并记录
2.5.3.2 常量
首先理解class文件中的常量池表
常量池表:class文件的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后存放到方法区的运行时常量池中
存储的数据类型:
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
#n表示符号引用
运行时常量池:类加载后在方法区生成,数据项通过索引访问,具有动态性
存储的数据类型:
- 编译期确定的字面量,
- 运行期才能确定的方法或字段引用,此时不再是符号引用,而是真实地址
2.5.4 方法区的演变
思考:静态变量到底是在方法区还是堆中
JDK1.7之前:放在方法区(随着类加载)
JDK1.7及以后:存放在堆中反射的class对象(即类加载后会在堆中生成一个对应的class对象)的尾部
思考:字符串常量池到底是在方法区还是堆中
JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区。
JDK1.7 的时候,字符串常量池被从方法区拿到了堆中。
原因:JDK1.7永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
字符串常量池:
这里的字符串即字符串字面量。
在声明一个字符串字面量时,如果在字符串常量池中能够找到,则直接返回该引用。如果找不到,则在常量池中创建该字符串字面量的对象并返回其引用。
String aa = "ab"; // 放在常量池中
String bb = "ab"; // 从常量池中查找
System.out.println(aa==bb);// true
对比JDK的版本变化(堆+方法区)
三、JVM调优参数
-Xms2g:初始化堆大小为 2g;
-Xmx2g:堆最大内存为 2g;
-XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
-XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
-XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
-XX:+PrintGC:打印 gc 信息;
-XX:+PrintGCDetails:打印 gc 详细信息。
四、GC垃圾回收
4.1 如何判断对象可被回收
一般有两种方法来判断:
- 引用计数器:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点
不能解决循环引用
的问题(两个或多个对象相互引用); - 可达性分析:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots
没有任何引用链
相连时,则证明此对象是可以被回收的。
4.2 引用类型
- 强引用:发生 gc 的时候
不会被回收
。可以通过将其显示的置为null,让gc认为该对象不存在引用而回收。 - 软引用:有用但不是必须的对象,在发生
内存溢出之前
会被回收。用SoftReference表示。 - 弱引用:有用但不是必须的对象,在
下一次GC
时会被回收。用WeakReference表示。 - 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在
gc 时返回一个通知
,主要用来跟踪对象被垃圾回收的活动,虚引用必须和引用队列
联合使用,程序通过发现一个引用队列中是否已经加入虚引用,来判断是否需要gc。
4.3 垃圾回收算法
- 标记-清除算法:标记无用对象,然后进行清除回收。
缺点:效率不高,无法清除垃圾碎片。 - 标记-整理算法:标记无用对象,让所有
存活的对象都向一端移动
,然后直接清除掉端边界以外的内存。 - 复制算法:按照容量
划分二个大小相等的内存
区域,当一块用完
的时候将活着的对象复制
到另一块上,然后再把已使用的内存空间一次清理掉。
缺点:内存使用率不高,只有原来的一半。 - 分代算法:根据
对象存活周期
的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法
,老年代采用标记-整理算法
。
4.4 垃圾回收器
-
Serial:最早的
单线程
串行垃圾回收器。 -
Serial Old:Serial 垃圾回收器的老年版本,同样也是
单线程
的,可以作为 CMS 垃圾回收器的备选预案。 -
ParNew:是 Serial 的
多线程
版本。 -
Parallel:和 ParNew 收集器类似是
多线程
的,但 Parallel 是吞吐量优先
的收集器,可以牺牲等待时间换取系统的吞吐量。 -
Parallel Old:是 Parallel 老生代版本,Parallel 使用的是复制的内存回收算法,Parallel Old 使用的是
标记-整理
的内存回收算法。 -
CMS(Concurrent Mark-Sweep):一种以获得
最短停顿时间
为目标、牺牲吞吐量为代价的收集器,非常适用 B/S 系统(要求服务器响应速度的应用)。
启动 :JVM 参数加上“-XX:+UseConcMarkSweepGC”
标记-清除
算法实现,会产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。 -
G1:一种
兼顾吞吐量和停顿时间
的 GC 实现,是JDK 9
以后的默认 GC 选项。
标记-整理
算法实现。
G1将整个堆分为大小相等的多个region,G1跟踪每个区域的垃圾大小,后台维护一个优先级列表
,每次根据允许的收集时间,优先回收价值最大的区域,从而达到在有限时间内获取尽可能高的回收效率。
4.4.1 分类
- 新生代回收器:Serial、ParNew、Parallel Scavenge
- 老年代回收器:Serial Old、Parallel Old、CMS
- 整堆回收器:G1
新生代垃圾回收器一般采用的是复制
算法,复制算法的优点是效率高,缺点是内存利用率低;
老年代回收器一般采用的是标记-整理
的算法进行垃圾回收。
4.4.2 分代垃圾回收器是怎么工作的
详见:Java堆 描述部分
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
- 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
- 清空 Eden 和 From Survivor 分区;
- From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。
老生代当空间占用到达某个值之后就会触发全局垃圾回收,一般使用标记-整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。