优秀的编程知识分享平台

网站首页 > 技术文章 正文

深入理解java虚拟机笔记(简述java虚拟机的概念)

nanyue 2024-08-29 20:51:05 技术文章 6 ℃

java内存区域

(1) Heap (堆区):Heap 是 OOM(Out Of Memory) 故障最主要的发源地 , 它存储着几乎所有的实例对象 ,堆由垃圾收集器自动回收 ,堆区由各子线程共享使用。通常情况下 , 它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间。

堆的内存空间既可以固定大小 ,也可以在运行时动态地调整,通过如下参数设定初始值和最大值 ,比如 -Xms256M -Xmxl024M ,其中-X表示它是 JVM 运行参数,ms 是 memory start 的简 称, mx 是 memory max 的简称,分别代表最小堆容量和最大堆容量。但是在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力 ,所以在线上生产环境中 , JVM 的 Xms 和 Xmx 设置成同样大小,避免在GC 后调整堆大小时带来的额外压力 。

堆分成两大块,新生代和老年代。对象产生之初在新生代 ,步入暮年时进入老年代 ,但是老年代也接纳在新生代无法容纳的超大对象。新生代= 1 个 Eden 区+ 2 个Survivor 区。绝大部分对象在 Eden 区生成,当 Eden 区装填满的时候,会触发 Young Garbage Collection ,即 YGC。垃圾回收的时候,在Eden区实现清除策略,没有被引用的对象则直接回收。依然存活的对象会被移送到Survivor 区。Survivor 区分为 s0 和 s1两块内存空间 , 送到哪块空间呢?每次 YGC 的时候,它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态。如果 YGC 要移送的对象大于 Survivor区容量的上限,则直接移交给老年代。对象不会一直在新生代的Survivor 区交换来交换去,每个对象都有一个计数器,每次 YGC 都会加1。-XX:MaxTenuringThreshold 参数能配置计数器的值到达某个阐值的时候 , 对象从新生代晋升至老年代。如果该参数配置为1 ,那么从新生代的 Eden 区直接移至老年代。默认值是 15 ,可以在 Survivor 区交换 14 次之后 ,晋升至老代。如果 Survivor 区无法放下,或者超大对象的阈值超过上限,则尝试在老年代中进行分配;如果老年代也无法放下,则会触发 Full Garbage Collection ,即FGC。如果依然无法放下,则抛出 OOM。堆内存出现 OOM 的概率是所有内存耗尽异常中最高的。出错时的堆内信息对解决问题非常有帮助 , 所以给 JVM 设置运行参数 -XX:+HeapDumpOnOutOfMemoryError ,让JVM遇到 OOM 异常时能输出堆内信息,特别是对相隔数月才出现的 OOM 异常尤为重要。

(2). Metaspace (元空间):在 JDK8中,元空间的前身永久区(同样是方法区的一种实现)已经被淘汰。在JDK7 及之前的版本中,只有 Hotspot才有 Perm区,译为永久代 ,它在启动时固定大小,很难进行调优,并且 FGC 时会移动类的元信息。在某些场景下,如果动态加载类过多,容易产生 Perm区的OOM。比如某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多的类,经常出现致命错误。

“exception in thread 'dubbo client x.x connector ' java.lang.OutOfMemory Error : PennGen

space”为了解决该问题 ,需要设定运行参数 -XX:MaxPermSize= 1280m ,如果部署到新机器上,往往会因为JVM参数没有修改导致故障再现。不熟悉此应用的人排查问题时往往苦不堪言,除此之外,永久代在垃圾回收过程中还存在诸多问题。所以,JDK8 使用元空间替换永久代。在 JDK8 及以上版本中,设定 MaxPermSize 参数,JVM 在启动时并不会报错,但是会提示:Java HotSpot 64Bit Server VM warning:ignoring option MaxPem1Size=2560m; support was removed in 8.0 。

区别于永久代 ,元空间在本地内存中分配。在 JDK8 中, Perm区中的所有内容中字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内。在常量池中的string其实际对象是被保存在堆内存中的。

方法区只是一个规范,规范没变,它就一直在。那么取代永久代的就是元空间。它和永久代有什么不同的?存储位置不同,永久代物理是是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存;存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。

Class文件常量池:指的是编译生成的 class 字节码文件,其结构中有一项是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。这里的字面量是指字符串字面量和声明为 final 的常量值,这些字符串字面量除了类中所有双引号括起来的字符串(包括方法体内的),还包括所有用到的类名、方法的名字和这些类与方法的字符串描述、字段(成员变量)的名称和描述符;声明为final的常量值指的是成员变量,不包含本地变量,本地变量是属于方法的。这些都在常量池的 UTF-8 表中(逻辑上的划分);符号引用,就是指指向 UTF-8 表中向这些字面量的引用,包括类和接口的全限定名(包括包路径的完整名)、字段的名称和描述符、方法的名称和描述符。只不过是以一组符号来描述所引用的目标,和内存并无关,所以称为符号引用,直接指向内存中某一地址的引用称为直接引用;

运行时常量池是方法区的一部分,是一块内存区域。Class 文件常量池将在类加载后进入方法区的运行时常量池中存放。一个类加载到 JVM 中后对应一个运行时常量池,运行时常量池相对于 Class 文件常量池来说具备动态性,Class 文件常量只是一个静态存储结构,里面的引用都是符号引用。而运行时常量池可以在运行期间将符号引用解析为直接引用。可以说运行时常量池就是用来索引和查找字段和方法名称和描述符的。给定任意一个方法或字段的索引,通过这个索引最终可得到该方法或字段所属的类型信息和名称及描述符信息,这涉及到方法的调用和字段获取。

(3) JVM Stack ( java虚拟机栈):栈( Stack )是-个先进后出的数据结构 ,相对于基于寄存器的运行环境来说,JVM是基于栈结构的运行环境。栈结构移植性更好,可控性更强。

JVM中的虚拟机栈是描述 Java 方法执行的内存区域,它是线程私有的。栈中的元素用于支持虚拟机进行方法调用 ,每个方法从开始调用到执行完成的过程 ,就是栈帧从入栈到出栈的过程。在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。而 StackOverflowError表示请求的栈溢出 , 导致内存耗尽,通常出现在递归方法中。

虚拟机通过压栈出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上。在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。

当前方法的栈帧 ,都是运行时的动态栈,包括局部变量表、操作栈、方法返回值、动态链接。

局部变量表:是存放方法参数和局部变量的区域。相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化。如果是非静态方法,则存储的是方法所属对象的实例引用,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内。

操作栈:操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写人和提取信息。 JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法的元信息的stack属性中。

动态连接:每个栈帧中包含一个在常量池中对当前方法的引用 , 目的是支持方法调用过程的动态连接。

方法返回地址:方法执行时有两种退出情况 第一,正常退出,即正常执行到任何方法的返回字节码指令 , 如RETURN ,第二,异常退出。无论何种退出情况,都将返回至方法当前被调用的位置。 方法退出的过程相当于弹出当前栈帧 ,退出可能有三种方式:返回值压入上层调用栈帧;异常信息抛给能够处理的栈帧;程序计数器指向方法调用后的下一条指令。

(4) Native Method Stacks (本地方法栈):在 JVM 内存布局中 ,也是线程对象私有的,但是虚拟机栈“主内 ”, 而本地方法栈“主外”。这个“内外”是针对 JVM 来说的,本地方法栈为 Native 方法服务。线程开始调用本地方法时,会进入一个不再受 JVM约束的世界。本地方法可以通过 JNI ( Java Native Interface )来访问虚拟机运行时的数据区 ,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。当大量本地方法出现时 , 势必会削弱JVM对系统的控制力,因为它的出错信息都比较黑盒。对于内存不足的情况,本地方法栈还是会抛出 native heap OutOfMemory。

重点说一下 JNI 类本地方法,最著名的本地方法应该是 System.currentTimeMillis() , JNI 使 Java 深度使用操作系统的特性功能,复用非 Java 代码。但是在项目过程中,如果大量使用其他语言来实现 JNI,就会丧失跨平台特性,威胁到程序运行的稳定性。假如需要与本地代码交互,就可以用中间标准框架进行解耦,这样即使本地方法崩溃也不至于影响到JVM的稳定。

(5) Program Counter Register (程序计数寄存器):在程序计数寄存器中, Register 的命名源于CPU 的寄存器,CPU只有把数据装载到寄存器才能够运行。寄存器存储指令相关的现场信息,由于 CPU 时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常 。

最后 , 从线程共享的角度来看,堆和元空间是所有线程共享的,而虚拟机栈、本地方法栈、程序计数器是线程内部私有的。

对象创建:执行new指令时,首先检查在常量池中是否能定位到类的符号引用,并检查符号引用代表的类是都已经被加载,解析和初始化过。通过后使用指针碰撞(假设堆是规整的 ,用过的和空闲的内存各放一边,把指针向空闲部分内存移动一段与对象大小相等的距离。)的方式执行内存分配。分配完成后,初始化分配的内存空间为零值。

对象内存布局

对象头:存储hash码、GC分代年龄、锁状态标志、线程持有锁、偏向线程id、偏向时间戳、对象指向他的类元数据的指针。如果对象是数组还需记录数组长度。

实例数据:由父类继承的或子类定义的都需要记录

对齐填充:由于HotSpot VM要求对象的起始地址必须是8字节的整数倍,所以当对象实例数据没有对齐就需要通过对齐填充来补全。

垃圾收集器和内存分配策略

判断对象是否已死

引用计数法:给对象添加一个引用计数器,有地方引用值加1,引用失效,计数器减一。实现简单,效率高,但无法解决对象互相循环引用的问题。

可达性分析法:通过一系列称为GC ROOTS的对象作为起始点,从这些开始向下搜索,走过的路径称为引用链,当一个对象到GC ROOTS没有任何引用链相连时,证明对象不可用,判定对象可回收。

可作为GC ROOTS的对象:栈中引用的对象、方法区类静态属性和常量引用的对象、JNI引用的对象。

强引用,即Strong Reference,最为常见。如 Object o = new Object();这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且 GC Roots可达,那么 Java 内存回收时,即使濒临内存耗尽,也不会回收该对象。

软引用, 即Soft Reference , 引用力度弱于强引用,在即将内存溢出之前,垃圾回收器会把这些软引用指向的对象加入回收范围,让程序能够继续健康运行。主要用来缓存服务器中间计算结果及不需要实时保存的用户行为等。但是,软引用肯定不是用来缓存高频数据的,万一服务器重启或者软引用触发大规模回收,所有的访问将直接指向数据库,导致数据库的压力时大时小,甚至崩溃。

弱引用,即Weak Reference , 引用强度较前两者更弱,也是用来描述非必需对象的。如果弱引用指向的对象只存在弱引用这条线路,则在下一次 YGC 时会被回收。由于YGC时间的不确定性,弱引用何时被回收也具有不确定性。弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象。调用 WeakReference.get()可能返回 null ,要注意空指针异常。

虚引用,即Phantom Reference,是极弱的一种引用关系,定义完成后,无法通过该引用获取指向的对象。为一个对象设置虚引用的唯一目的就是希望能在这个对象被回收时收到一个系统通知。虚引用必须与引用队列联合使用,当垃圾回收时,如果发现存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的可引用队列中。

内存回收:真正回收一个对象要经历两次标记过程。第一次标并筛选对象是否有必要执行finalize方法,没有必要就不回收。如果有必要,则将对象放至一个F-Queued的队列中,稍后GC会在队列中的对象进行第二次标记。如果对象还没有逃脱,则会被回收。

回收方法区:主要回收废弃常量和无用的类

废弃常量即没有任何对象引用。无用的类即所有实例被回收,所有加载该类的ClassLoader被会后,该类对应的class对象没有任何引用。

从以上条件可以看出方法区的回收性价比是很高的,回收条件很苛刻。

垃圾收集算法

标记-清除算法,从GC Roots出发,一次标记有引用关系的对象,最后将没有被标记的对象清除。这种算法会带来大量的空间碎片导致需要分配一个较大连续空间时容易触发 FGC。为了解决这个问题,又提出了:

标记一整理算法:该算法类似计算机的碰盘整理,首先会从 GC Roots 出发标记存活的对象,然

后将存活对象整理到内存空间的一端,形成连续的已使用空间,最后把已使用空间之外的部分全部清理掉,这样就不会产生空间碎片的问题。能够并行地标记和整理将空间分为两块,每次只激活其中一块,垃圾回收时只需把存活的对象复制到另一块未激活空间上,将未激活空间标记为己激活,将己激活空间标记为未激活,然后清除原空间中的原对象。堆内存空间分为较大的 Eden 和两块较小的Survivor ,每次只使用Eden和Survivor区的一块。这也是主流YGC算法进行新生代垃圾回收。

复制算法:将可用的内存分为大小相等的两块,每次只使用一块,将这一块用完了,就把活着的对象复制到另外一块上,把已使用的一块一次清理掉。代价是牺牲一部分内存空间。

垃圾收集器:

serial收集器:单线程,收集时需要暂停其他工作线程。

ParNew收集器:是serial的多线程版本。

parallel scavenge:新生代收集器,采用复制算法,并行的多线程收集。

CMS收集器:获取最短回收停顿时间为目标的收集器,采用标记清除算法。

G1收集器:并行与并发进行垃圾回收,使用标记整理算法实现。

空间分配担保:发生minor GC前,会先检查老年代最大可用连续空间是否大于年轻代所有对象,成立则minor GC是安全的。否则JVM会查看是否允许担保,允许会检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,小于或者不允许担保就会发起一次Full GC。

JVM性能监控

jstat:显示JVM的类装载、内存、垃圾收集等数据。



jinfo:查看和调整虚拟机的参数

jmap:生成堆转储快照。



jstack:生成JVM虚拟机当前时刻的线程快照。用以分析线程死锁,线程卡顿等情况。

class类文件结构


class文件前四个字节是魔数,确定该文件是否可被JVM接受的class文件。

接近着四个字节是class版本号,前两个是主版本,后两个是副版本号。

接下来是常量池容量计数值,紧接着存储常量池表。常量池主要存储字面量和符号引用。常量池包括如文本字符串和final常量等。符号引用包括类和接口的全限定名、字段和方法的名称和描述符。当JVM运行时,会从常量池获取符号引用翻译到具体的内存地址中。

紧接着两个字节代表访问标志。

类索引和父索引是u2类型的数据用于确定该类和父类的全限定名,接口索引是一组u2类型的集合,确定接口的全限定名。所谓全限定名就是com/spring...这种形式。而方法和字段的描述符作用是描述字段的类型方法参数列表和返回值。

字段表用于描述接口或者类中声明的变量,包括实例变量和静态变量。

方法结合表和字段表的作用差不多,用于描述防范方法。

JVM类加载机制


加载:通过类全限定名获取类二进制字节流,将字节流转换为方法区运行时数据结构,生成一个代表着这个类的class对象,作为方法区中这个类的访问入口。

验证:确保class文件的字节流包含的文件信息安全符合JVM要求。

包括文件格式验证(文件头、版本等);元数据验证(校验类是否继承不被允许的类等,保证不存在不合符java规范的元数据信息);字节码验证(对类的方法校验,保证不会出现危害虚拟机的代码);符号引用验证(在将符号引用转换为直接引用时发生,检验全限定名是否能找到对应的类等)。

如果运行代码可以保证安全的情况,可以使用-Xverify:none关闭类验证,缩短加载时间

准备:为类变量(static变量)分配内存并设置初始值,在方法区中进行分配。

解析:将常量池中符号引用替换为直接引用。

初始化:根据程序猿指定的值初始化类变量

双亲委派模型:如果一个类加载器收到类加载请求,首先不会自己去加载这个类,而是请求委派父类加载器去加载,每一层的类加载器都如此,所以所有的请求都会传送到启动类加载器中,只有父类加载器无法完成子类加载器才会自己尝试进行加载。

启动类加载器:负责加载JAVA_HOME/lib目录下的

扩展类加载器:负责加载JAVA_HOME/lib/ext目录中的

应用程序类加载器:负责用户类路径上所指定的

方法调用:确定调用哪一个方法,并不是方法的执行。包含解析和分派

解析:编译期间就完全确定的静态过程,类加载的解析阶段就会把符号引用转换为直接引用。例如final、static等方法的方法调用。

分派:

静态分派:

例Father f = new Son();Father称之为静态类型,Son称之为实际类型。JVM在参数的重载时是通过参数的静态类型作为判断依据的。依赖静态类型定位方法执行版本的分派动作称为静态分配。典型的应用就是方法的重载(重载的实现原理)。

动态分派:例Father f = new Son();Father称之为静态类型,Son称之为实际类型。JVM在重写时是通过参数的实际类型作为判断依据的。依赖实际类型定位方法执行版本的分派动作称为动态分配。典型的应用就是方法的重写(重写的实现原理)。

Tags:

最近发表
标签列表