深入理解Java虚拟机
2022-05-23 14:55:25 1 举报
AI智能生成
JVM最全总结
作者其他创作
大纲/内容
jps(JVM Process Status)查看所有Java进程
jstat(JVM Statistics Monitoring Tool)监视虚拟机各种运行状态信息
jinfo(Configuration Info for Java)实时查看和调整虚拟机的各项参数
jmap(Memory Map for Java):生成堆转储快照
jhat(JVM Heap Dump Browser)用于分析heapdump文件
jstack(Stack Trace for Java)生成虚拟机当前时刻的线程快照
JVM 性能监控
没有任何指针指向的对象就是垃圾(循环引用依赖除外)
什么是垃圾?
给每一个对象都加一个引用计数器,每当有一个引用指向该对象,那么对象的引用计数器加一。
缺点:无法解决循环依赖问题
引用记数法
从GC ROOT作为起始点,开始向下遍历,搜索路径称为“引用链”,当一个对象到GC ROOT有一条完整的引用链,那么说明此对象就是可达的。
可达性分析算法(默认算法)
堆的周边对象(虚拟机栈、本地方法栈、方法区)
栈帧中的局部变量表
虚拟机栈中的引用的对象
一般是Native方法
本地方法栈中的引用对象
方法区中静态常量引用的对象
方法区中常量引用的对象
临时GC ROOTS,在进行垃圾回收时,老年代可以指向新生代中的对象并临时作为GC ROOTS。
GC ROOTS
判断对象是否为垃圾
Strong Reference:垃圾收集器永远不会回收掉被强引用引用者的对象
强引用:不会被回收
Soft Reference:一些有用但非必需的对象,如果内存不足,那么就会回收软引用,如果内存还不足,那么就会报内存溢出异常
内存不足一般包括OOM和老年代空间不足
使用软引用情况较多,因为软引用能够加快JVM对垃圾回收的速度,可以维护系统的运行安全,防止内存溢出等问题。
软引用:内存不足即回收
Weak Reference:强度比软引用还弱一些。不论内存是否溢出,垃圾收集器都会回收。
一般很少使用
弱引用:发现即回收
Phantom Reference:在这个垃圾被垃圾收集器回收时给系统发一个通知。
必须和引用队列一起使用
虚引用:追踪对象的回收过程
在GC时,终结器引用入队列。由finalizer线程通过终结器引用找到被引用对象的finalize方法。
终结器引用:实现对象的finalize方法
四大引用(5)
真正宣告一个对象死亡,至少要经历两次垃圾回收过程。
不可达的对象第一次进行标记并进行一次筛选,筛选的条件是 此对象是否有必要执行finalize方法。如果对象没有 finalize 方法或者以及执行过finalize方法,意味着没有必要执行 finalize 方法
如果有必要执行 finalize 方法,被判定为垃圾的对象会被放在队列中进行第二次标记,此过程如果还没有指向它的引用,那么就会被回收。
不可达的对象”非死不可“?
废弃常量:如果没有任何引用指向常量的话,那么就是废弃常量。
该类的所有 实例 都已经被回收,堆中不存在该类的任何实例
加载该类的 ClassLoader 已被回收
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法通过反射访问该类的方法
废弃的类:方法区主要回收无用的类。需要同时满足左边三个条件....
如何判断废弃的常量、类?
过程:1、首先标记所有的可达对象;2、清除没有被标记的对象;
缺点:1、标记、清除阶段效率都不高;2、碎片化内存。可能会导致无法分配大对象。
标记 - 清除算法
过程:1、将内存分为两份,每次只使用其中的一份,把可达的对象复制到一块内存上;2、清除另一半的空间;
缺点:避免了内存碎片化,但内存缩小了一半
在新生代中,大部分的对象都是”朝生夕死“的,所以只需付出少量的复制成本就可以完成GC
因为新生代中的对象98%是”朝生夕死“的,所以不需要1:1来分配。Eden区和两个Survivor区的比例为8:1:1,每次只使用Eden区和一块Survivor区
当回收时,首先在Eden区和一块S1区分配对象,进行Minor GC时,首先把存活的对象复制到为空的S2区,再清除Eden区和S1区的所有对象
老年代为新生代分配担保
新生代的垃圾回收算法
复制算法
过程:1、首先将可达的对象复制到内存的另一侧;2、清除另一侧的所有对象
老年代中大部分的对象存活时间较长,并且没有额外的空间对其进行分配担保,所以一般采用”标记-清楚“或”标记-整理“算法
老年代的垃圾回收算法
标记 - 整理算法
垃圾回收算法(3)
为什么要有TLAB?
1、JVM对象的创建是非常频繁的,在并发环境下划分内存是不安全的。
2、避免多个线程操作同一地址。如果没有TLAB的话,那就需要使用加锁,影响分配速度
对象首先在每个线程的TLAB上分配,TLAB默认开启,如果TLAB分配不下,就在Eden区分配
大多数情况下,对象首先在Eden区分配,如果内存不足,会进行一次Minor GC
大对象直接进入老年代,避免了在新生代中来回复制,比如字符串和数组
每一个对象都有一个年龄计数器【0-15】,每经过一次Minor GC并且存活下来,年龄计数器值+1,当超过15岁,对象直接进入老年代
进入老年代的年龄可以通过参数-XX:MaxTenuringThreshold设置
长期存活的对象进入老年代
对象并不一定是年龄到达15岁才会进入老年代。如果Survivor区中相同年龄的对象大小超过内存大小的一半,那么年龄大于等于该年龄的对象将直接进入老年代
动态对象年龄判断
当对象进行一次Minor GC时,首先会检查 ”老年代中连续内存的大小是否 大于 当前新生代中所有对象的大小“ ?
如果大于,正常进行Minor GC;否则虚拟机会去检查以往每次Minor GC后存活对象的大小是否 小于 老年代中连续空间的大小?
如果小于,进行Minor GC,即使是有风险的;否则老年代会先进行一次Full GC,以便给新生代进行分配担保。
只要老年代中的连续内存大小 大于 新生代中的所有对象大小 或者 大于 历次Minor GC之后存活对象的平均数大小 会进行 Minor GC,否则进行 Full GC
空间分配担保
1、对象首先尝试栈上分配,2、失败的话就在TLAB上分配,3、TLAB分配满后在Eden区上,也有可能进入老年代
内存分配与回收策略(5)
Minor GC发生在新生代中的GC,Eden区满会触发 Minor GC,Survivor满不会触发。因为大多数的对象是 ”朝生夕死“ 的,所以Minor GC比较频繁
Minor GC
发生在老年代的垃圾回收,一般会伴随这至少一次的Minor GC,速度比Minor GC慢10倍以上。
Full GC
可通过 -XX:+DisableExplicitGC 来禁止RMI调用System.gc()
调用 System.GC(),建议虚拟机进行一次Full GC,但不一定会真的执行。不建议使用这种方式
尽量不要创建大的字符串和数组
通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉
通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄
老年代的空间不足
进行Minor GC之前,如果老年代中的连续内存大小 小于 新生代中所有的对象大小
如果老年代中连续内存的大小 小于 新生代中历次Minor GC后存活对象的平均数
空间分配担保失败
执行 CMS GC 的过程中 同时有对象放入老年代,而此时老年代中的空间不足,便会报CMF错误
Concurrent Mode Failure
JDK 1.7及以前永久代空间不足
Full GC的触发条件(5)
Minor GC和Full GC
标记一下 GC ROOTS 能够关联到的对象,速度很快
1、初始标记
遍历整个GC Roots Tracing 对象图
2、并发标记
为了修订并发标记期间因用户程序继续运行而导致变动的那部分对象
3、重新标记
不需要停顿掉用户线程。删除标记阶段判断已经死亡的对象
4、并发清除
缺点:1、吞吐量低;
2、无法处理浮动垃圾,并发清除阶段因用户进程继续运行而导致变动的对象;
3、标记 - 清除算法导致空间内存碎片化;
CMS(4)
G1 可以将新生代和老年代中的对象一起回收,G1 把堆分成大小相等的独立区域,
通过记录每个 独立区域 的垃圾回收时间和回收所获得的内存大小,维护一个优先列表,优先回收价值最大的 Region
每个 Region 都有一个 Remembered Set ,用来记录引用此对象的 Region 对象。
修正在并发标记期间因用户继续运行而导致变动的那部分对象
3、最终标记
对每个 Region 的回收时间和回收所获得的空间进行排序,优先回收价值大的 Region
4、筛选回收
优点:1、并行与并发;
2、分代收集:G1可以对整个GC堆进行收集,保留了逻辑上的新生代和老年代;
3、空间整合:整体来看采用 ”标记-整理“算法,两个 Region 使用复制算法;
4、可预测的停顿:用户线程不执行,根据允许的时间,优先回收价值最大的Region
缺点:1、垃圾收集产生的内存占用和程序运行时的额外负载都比CMS更高;
2、从经验上看,在小内存上CMS的表现大概率会优先于G1,而G1在大内存应用上发挥其优势
G1(4)
超过50%的Java堆被活动数据占用
对象的提升频率或年代提升频率变化很大
GC停顿时间过长(长至0.5-1s)
以下情况G1比CMS更好
垃圾回收器(7)
垃圾回收机制
通过 类的全限定名 获取类的 二进制字节流
把字节流所代表的 静态存储结构 变为 方法区的运行时存储结构
Class 对象分配在堆中
生成一个代表该类的 Class 对象,作为该类中各种方法访问的入口
加载
确保字节流中的文件符合虚拟机的要求,并且不会危害虚拟机。
验证
类变量存储在Class对象中
为类变量分配内存并设置初始值
初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。public static int value = 123;
如果类变量是常量,那么它将初始化为表达式所定义的值而不是 0。例如下面的常量 value 被初始化为 123 而不是 0。public static final int value = 123;
实例变量不会在此阶段分配内存,实例变量在对象实例化的时候分配内存。实例化不是类加载的一个过程。类加载只能有一次,而实例化可以多次
准备
符号引用:未加载到内存。一组描述各种引用的符号,可以是字面量。
直接引用:已加载到内存。直接指向目标的指针,有具体的引用地址。
将常量池的 符号引号替换为 直接引用
解析
<clinit>由编译器自动收集类中所有的 类变量的赋值动作 和 静态语句块 合并产生的。
父类的<clinit>方法早于子类的<clinit>方法的执行
public class Test { static { i = 0; // 给变量赋值可以正常编译通过 System.out.print(i); // 这句编译器会提示“非法向前引用” } static int i = 1;}
静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。
ClassLoader源码:synchronized (getClassLoadingLock(name)) { }
虚拟机会保证一个类的<clinit>方法在多线程的情况下被正确的加锁和同步。如果多个线程同时初始化一个类,那么只会有一个线程执行会执行<clinit>方法,其他线程阻塞。
执行类构造器<clinit>方法的过程
接口和类不同的是:接口的<clinit>方法不需要先执行父类的<clinit>方法。只有用到父类接口中的常量时才会进行初始化。
接口中也有类变量的赋值操作,因此接口与类一样都会生成<clinit>方法
初始化
使用+卸载
类加载过程
手动 new 一个对象时
除外:被 final 修饰的变量,在编译期就会被放入常量池中的字段除外
调用或设置一个类的静态变量
调用一个类的静态方法
遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类还没有初始化,就进行初始化。
使用 java.lang.reflect 进行反射调用类的时候,如果类还没有初始化,那么会进行初始化
当初始化一个类时,如果父类还没有初始化,那么先进行父类的初始化
虚拟机启动时,会先初始化 main() 方法所在的主类
主动引用(4)
通过子类引用父类的静态字段,不会初始化子类
该过程会对数组类进行初始化
数组类是一个由虚拟机产生的,继承自Object类,包含了数组的定义和方法。
通过数组定义来引用类,不会触发此类的初始化
常量在编译阶段会存储到调用类的常量池中,没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
被动引用(3)
类初始化时机
这里相等包括:Class 对象的 equals()、isAssignableFrom()、和isInstance() 方法返回结果都为true,也包括使用instanceof做对象所属关系判定结果为true。
两个类相等,需要两个类本身相等,并且需要使用同一个类加载器进行加载。因为每一个类加载器都有一个独立的类名称空间。
这三种类加载器是组合关系,而非继承关系
启动类加载器(BootstrapClassLoader)
扩展类加载器的父类加载器为null,null并不代表没有父类加载器,而是启动类加载器!
扩展类加载器(ExtensionClassLoader)
应用程序类加载器(AppClassLoader)
继承ClassLoader类,重写 loadClass() 方法
用户自定义类加载器
自底向上检查类是否被加载,自顶向下尝试去加载类!
类加载器(3)
每一个类都有一个对应的类加载器
当类加载的时候,虚拟机首先检查该类是否已经被加载过,如果加载过,就直接返回。
否则类加载请求会 请求委派父类类加载的loadClass(),因此所有的请求最终都会传送到顶层的BootstrapClassLoader中。如果无法处理,再由自己处理。
当父类的加载器为null时,启动BootstrapClassLoader类加载器
双亲委派模型
类与类加载器
JVM 区分两个不同的类不仅仅是根据名称,相同的两个文件被不同的类加载器加载也会生成两个不同的类。
避免了类的重复加载
如果我们自定义一个java.lang.String,那么启动的时候就会报错。错误:在类 java.lang.String 中找不到 main 方法。
防止API被篡改
双亲委派模型的优点
当前线程执行字节码的行为指示器,记录的是当前虚拟机执行字节码的指令地址。
比如:分支、跳转、异常、线程恢复等都需要这个程序计数器。
作用:1、字节码解释器通过改变程序计数器的值来选择下一条要执行的指令地址;
2、多线程情况下,程序计数器保存当前线程的执行地址,以便线程恢复后能从正确的位置开始执行。
如果执行的是Native方法,程序计数器的值为0
程序计数器
Java虚拟机栈是描述Java方法运行过程的内存模型。它会为每一个方法创建一个栈帧,每一个方法从调用到结束的过程,都对应着一个栈帧从入栈到出栈的操作。
存放的是方法参数和一些局部变量等信息。比如:数据类型、对象引用
局部变量表
保存计算过程中的临时变量
操作数栈
指向运行时常量池中的方法引用
动态链接
存放调用该方法的PC寄存器的值,就是调用该方法指令的下一条地址。
方法出口
与调试相关的信息
附加信息
StackOverflowError:线程的请求深度大于虚拟机的最大深度,并且虚拟机的深度不能被扩展。内存可能还有很多。
OutOfMemoryError:线程的请求深度大于虚拟机的最大深度。允许动态扩展,但无法申请足够内存,并且内存也用完了。
可能会出现的两种异常:StackOverflowError和OutOfMemoryError
虚拟机栈
和虚拟机栈类似。虚拟机栈为执行Java方法服务,而本地方法栈为虚拟机使用到的Native方法服务。
本地方法被执行时,在本地方法中创建栈帧,用于存放本地的局部变量表,操作数栈,动态链接,方法出口等信息。
本地方法栈
垃圾收集的主要场所,几乎所有的Java对象和数组都在堆上存储。
Java堆分为新生代和老年代,具体细分还可以分为Eden区、Survivor区和Old区。进一步划分是为了更好的回收垃圾,更快的分配内存。
进入老年代的年龄可以通过参数:-XX:MaxTenuringThreshold 来设置。最大值15。
大部分情况,对象首先在Eden区分配,经过一次新生代垃圾收集后,如果对象还活着,就会进入S1或S2区,同时对象的年龄+1(第一次Eden到Survivor年龄初始为1)。当年龄计数器到达一定阈值(默认15岁),那么就会被移入老年代。
Java堆可以划分线程的私有缓冲区(TLAB)
不是。如果经过逃逸分析后,如果没有发生逃逸,那么就被优化为栈上分配。
逃逸分析:当一个对象在方法中被定以后,对象只在方法内部调用,则认为没有发生逃逸。
public void my method() { V v = new V(); v = null;}
堆是对象分配的唯一选择吗?
-XX:+PrintFlagsInitial: 查看所有的参数的默认初始值-XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值) 具体查看某个参数的指令:jps:查看当前运行中的进程 jinfo -flag SurvivorRatio 进程id
堆参数
GC堆
子主题
存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。被 static final 修饰的全局变量在编译时就被分配了。
JDK 1.7及以后存储类信息、域信息、方法信息
方法区是Java虚拟机中的一种规范,而永久代是HotSpot的概念。一个是规范,一个是实现。
JDK 1.7方法区就被移除了,取而代之的是元空间,元空间使用的是本地内存。
方法区和永久代的关系?
永久代有一个设置固定大小的上限;而元空间使用的是本地内存,永远不会发生OutOfMemoryError。
元空间大小可通过参数 -XX:MaxMetaspaceSize设置,默认值-1。这意味着元空间只受系统内存大小限制
为什么将方法区替换为元空间?
方法区
字面量:字符串、被声明为 final 的常量值
类引用
域引用
方法引用
名称和类型
符号引号:1、类和接口的全限定名;2、字段的名称和描述符; 3、方法的字段和描述符。
主要存放数值常量、字面量、符号引用
运行时常量池是方法区的一部分。在JDK 1.7 中把方法区中的运行时常量池移动到了堆中。
动态性:JVM运行期间允许动态的存放常量,比如:String的 intern()方法
运行时常量池
不是虚拟机运行时数据区的一部分,也不是JVM中定义的规范,但这部分内存被频繁使用。
JDK 1.4中的一个NIO类,引入了一种基于通道和缓存区的 I/O 方式,可以使用 Native 函数库直接分配堆外内存,通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用。因为这样可以避免在Java堆和Native堆之间来回复制数据。
直接内存
线程私有:程序计数器、虚拟机栈、本地方法栈。线程共享:堆和方法区。
方法区 虚拟机栈 Java堆Customer cust = new Customer();
总结
JVM中的执行引擎负责将高级语言转换为机器语言
将字节码文件中的内容 “翻译” 成对应平台的本地机器指令的执行。
解释器
Just In Time:将源代码直接翻译成和本地机器平台相关的机器语言,速度比解释器快。
当 Java虚拟机启动时,解释器可以首先发挥作用,而不必等待 JIT 即时编译器全部编译完后再执行,这样可以省去很多不必要的编译时间。
解释器和 JIT 即时编译器并存
JIT 代码缓存:当方法调用超过一定的阈值,就会被缓存起来。
方法区包括 JIT 代码缓存
JIT 即时编译器
GC 垃圾收集器
执行引擎
JVM内存模型
手动创建
new 方式
只能调用空参构造器,Class的newInstance()方法
反射创建对象
当前类要实现Cloneable接口,调用Object的clone()方法
克隆
从文件中、从网络中读取一个对象的二进制流
反序列化
创建对象的方式
检查对象所属的类是否被加载、解析、初始化过。如果没有,那就执行对象的类加载过程。
类加载检查
原理:用过的内存放到一边,没有用过的内存放到另一边,中间有一个分界指针,只需向没有用过的内存方向移动对象大小即可
垃圾收集器带有压缩功能(内存规整):指针碰撞
原理:虚拟机会维护一个列表,用来记录哪些内存是可用的,在分配的时候,就找一块总够大的内存空间分配实例
垃圾收集器带有压缩功能(内存不规整):空闲列表法
CAS是乐观锁的一种实现。所谓乐观锁就是每次假设没有冲突去完成某项操作,如果失败就自旋重试,直到成功为止。
CAS+失败重试
每一个线程预先在Eden区分配一块内存,JVM在分配内存时,首先在TLAB上分配,当对象大于TLAB中的剩余内存或TLAB内存已用尽时,再采用CAS+失败重试的方式进行分配。
TLAB(缓冲区)
内存分配并发问题
分配内存,实例化
内存分配完成后,虚拟机会将分配到的内存空间都初始化为零值(不包含对象头),保证了对象的实例字段在不赋值的情况下就可以使用。
初始化零值,初始化
运行时元数据:对象的哈希码、对象的分代年龄等信息。是否启用偏向锁。
类型指针:指向方法区中对象的所属类的指针。
设置对象头
也叫实例化
真正地将对象给实例化
执行<init>方法
对象的创建过程
运行时元数据:哈希码、GC分代年龄、锁状态标志
类型指针:对象指向所属类的指针,JVM通过指针来确定这个对象是哪个类的实例。
对象头
存储对象的真正有效信息,比如对象属性等信息
实例数据
起占位符的作用。对象头是8字节的整数倍,实例数据是任意字节,对齐填充确保对象时8字节的整数倍。
对齐填充
Object o = new Object();占了多少个字节?16个字节
对象的内存布局
堆中有一块“句柄池”的内存空间,存储的是对象实例数据的指针和对象类型数据的指针。
好处:reference 中存储的是稳定的地址,对象被移动的时候只会改变句柄池中实例数据的指针,而 reference 不用改变。
句柄访问
reference类型的变量直接存放的是对象的地址,不需要句柄池,能够通过引用直接访问对象。
好处:访问速度比句柄访问快,节省了一次指针定位的开销。
直接指针
对象的访问定位
对象的创建和布局
深入理解Java虚拟机 (1.8HotSpot)
0 条评论
回复 删除
下一页