前言
JVM是Java的重要组成部分,对于我这个Cpper转Javaer也需要认真学习才对。
一、JVM内存结构
JDK 1.8 同 JDK 1.7 比,最大的差别就是:元数据区取代了永久代。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。
1、程序计数器(PC寄存器)
程序计数器:一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。若当前线程正在执行的是一个本地方法,那么此时程序计数器为Undefined
。
程序计数器的作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。
- 在多线程情况下,程序计数器记录的是当前线程执行的位置,从而当线程切换回来时,就知道上次线程执行到哪了。
程序计数器的特点:
- 是一块较小的内存空间。
- 线程私有,每条线程都有自己的程序计数器。
- 生命周期:随着线程的创建而创建,随着线程的结束而销毁。
- 是唯一一个不会出现
OutOfMemoryError
的内存区域。
2、Java虚拟机栈
Java虚拟机栈:Java方法运行过程中的内存模型。
Java虚拟机栈会为每一个即将运行的Java方法创建一块叫做“栈帧
”的区域,用于存放该方法运行过程中的一些信息,如:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError
错误。
栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
虚拟机栈可能出现的两种错误:
StackOverFlowError
OutOfMemoryError
3、本地方法栈(C栈)
本地方法栈则为虚拟机使用native方法服务。 在HotSpot虚拟机中和Java虚拟机合二为一。
栈帧变化过程:
本地方法被执行时,在本地方法栈也会创建一块栈帧,用于存放该方法的局部变量、操作数栈、动态链接、方法出口信息等。
方法执行结束后,相应的栈帧也会出栈,并释放内存空间。也会抛出StackOverFlowError和OutOfMemoryError异常。
如果Java虚拟机本身不支持Natvie方法,或是本身不依赖于传统栈,那么也不提供本地方法栈。如果支持本地方法栈,那么这个栈一般会在线程创建的时候按线程分配。
4、堆(认真复习,这一块好复杂)
此内存区域的唯一目的就是存放对象实例,几乎
所有的对象实例以及数组都在这里分配内存。
从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
堆的特点
- 线程共享,整个Java虚拟机只有一个堆,所有的线程都访问同一个堆。
- 在虚拟机启动时创建。
- 是垃圾回收的主要场所。
- 堆可分为新生代(Eden区:From Survior,To Survior)、老年代。
- Java虚拟机规范规定,堆可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的。
- 关于Surviror s0,s1区:复制之后有交换,谁空谁是to。
不同的区域存放不同的生命周期的对象,这样可以根据不同的区域使用不同的垃圾回收算法,更具有针对性。
堆的大小既可以固定地也可以扩展,但对于主流的虚拟机,堆的大小是可扩展的,因为当线程请求分配内存,单堆已满,且内存已无法再扩展时,就抛出OutOfMemoryError异常。
新生代与老年代
- 老年代比新生代生命周期长。、
- 新生代与老年代空间默认比例1:2:JVM调参数,XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。
- HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1。
- 几乎所有的Java对象都是在Eden区被new出来的,Eden放不下的大对象,就直接进入老年代了。
对象分配过程
- new的对象先放在Eden区,大小有限制;
- 如果创建新对象时,Eden空间填满了,就会触发Minor GC,将Eden不再被其他对象引用的对象进行销毁,再加载新的对象放到Eden区,特别注意的是Survivor区满了是不是触发Minor GC的,而是Eden空间填满了,Minor GC才顺便清理Survivor区,将Eden中剩余的对象移到Survivor0区
- 再次触发垃圾回收,此时上次Survivor下来的,放在Survivor0区的,如果没有回收,就会放到Survivor1区
- 再次经历垃圾回收,又会将幸存者重新放回Survivor0区,依次类推
- 默认是15次的循环,超过15次,则会将幸存者区幸存下来的转去老年区,jvm参数设置次数:-XX:MaxTenuringThreshold=N进行设置
- 频繁在新生区收集,很少在老年区收集,几乎不在永久区/元空间收集
Full GC/Major GC触发条件
目前已出现的三种GC方式:Major GC、Minor GC、Full GC
- 显示调用System.gc(),老年代的空间不够,方法区的空间不够等都会触发Full GC,同时对新生代和老年代回收,Full GC的STW的时间最长,应该要避免
- 在出现Major GC之前,回显触发Minor GC,如果老年代的空间还是不够就会触发Major GC,STW的时间长度Minor GC
逃逸分析
标量替换
- 标量不可在分解的量,java 的基本数据类型就是标量,标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在 JAVA 中对象就是可以被进一步分解的聚合量
- 替换过程,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。
- 使用逃逸分析,编译器可以对代码做如下优化:
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 将堆分配转化为栈分配:如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
TLAB
TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存去,是属于Eden区的,这是一个线程专用的内存分配区域,线程私有,默认开启的(当然也不是绝对的,也要看哪种类型的虚拟机)
当然并不是所有的对象都可以在 TLAB 中分配内存成功,如果失败了就会使用加锁的机制来保持操作的原子性
-XX:+UseTLAB使用 TLAB,-XX:+TLABSize 设置 TLAB 大小
5、方法区
Java 虚拟机规范中定义方法区是堆的一个逻辑部分。
方法区的特点
- 线程共享。方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。
- 永久代。方法区中的信息一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,把方法区称为"永久代"。
- 内存回收效率低。方法区中的信息一般需要长期存在,回收一遍之后可能只有少量信息无效。主要回收目标是:对常量池的回收;对类型的卸载。
- Java虚拟机规范对方法区的要求比较宽松。和堆一样,允许固定大小,也允许动态扩展,还允许不实现垃圾回收。
运行时常量池
常量就存放在运行时常量池内。
当类被Java虚拟机加载后,.class
文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如String类的intern()方法就能在运行期间向常量池中添加字符串常量。
6、直接内存(堆外内存)
操作直接内存
在NIO中引入了一种基于通道和缓冲的IO方式。它可以通过调用本地方法直接分配Java虚拟机之外的内存,然后通过一个存储在堆中的DirectByteBuffer对象直接操作该内存,而无须先将外部内存中的数据复制到堆中再进行操作,从而提高了数据操作的效率。
直接内存的大小不受Java虚拟机控制,但既然是内存,当内存不足时就会抛出OutOfMemoryError异常。
直接内存与堆内存比较
- 直接内存申请控件耗费更多的性能
- 直接内存读取IO的性能要优化普通的堆内存
- 直接内存作用链:本地IO -> 直接内存 -> 本地IO
- 堆内存作用链: 本地IO -> 直接内存 -> 非直接内存 -> 直接内存 -> 本地IO
二、HotSpot 虚拟机对象探秘
对象的内存布局
对象头可能包含类型指针,通过该指针能确定对象属于哪个类。如果对象是一个数组,那么对象头还会包括数组长度。
实例数据部分就是成员变量的值,其中包括父类成员变量和本类成员变量。
对齐填充用于确保对象的总长度为 8 字节的整数倍。
对象的创建过程
类加载检查:
虚拟机在解析.class文件时,若遇到一条 new 指令,首先它会去检查常量池中是否有这个类的符号引用,并且检查这个符号引用所代表的类是否已被加载、解析和初始化过。如果没有,那么必须先执行相应的类加载过程。
为新生对象分配内存,分配堆中内存有两种方式:
- 指针碰撞
- 空闲列表
初始化:
分配完内存后,为对象中的成员变量赋上初始值,设置对象头信息,
对象的访问方式
所有对象的存储控件都是在堆中分配的,但是这个对象的引用却是在堆栈中分配的。
也就是说在建立一个对象时需要两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存知识一个指向这个堆对象的指针(引用)而已。
句柄访问方式:
直接指针访问方式:
三、垃圾收集策略与算法
垃圾收集主要是针对堆
和方法区
进行;程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。
判断对象是否存活
若一个对象不被任何对象或变量引用,那么它就是无效对象,需要被回收。这个地方的实现类似于C++的指针指针,感兴趣的同学可以去对比下。
可达性分析法
所有和 GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots 没有关联的对象就是无效对象。
GC Roots 是指:
- Java 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中引用的对象
- 方法区中常量引用的对象
- 方法区中类静态属性引用的对象
GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。
引用的种类
回收堆中无效对象
对于可达分析中不可达的对象,也并不是没有存活的可能。
判定finalize()是否有必要执行
JVM会判断此对象是否有必要执行finalize()方法,如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么视为"没有必要执行"。那么对象基本上就针对被回收了。
如果对象被判定为有必要执行finalize()方法,那么对象会被放入一个F-Queue队列中,虚拟机会以较低的优先级执行这些finalize()方法,但不会确保所有的finalize()方法都会执行结束,如果finalize()方法出现耗时操作,虚拟机就直接停止指向该方法,将对象清除。
对象重生或死亡
如果在执行finalize()方法时,将this赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。
任何一个对象的finalize()方法只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,想继续在finalize()中自救就失效了。
回收方法区内存
方法区中存放生命周期较长的类信息、常量、静态变量,每次垃圾收集只有少量的垃圾被清除。
方法区中主要清除两种垃圾:
- 废弃常量:只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。
- 无用的类:
- 该类的所有对象都已经被清除
- 加载该类的ClassLoader已经被回收
- 该类的java.lang.Class对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法
一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区时创建,在方法区该类被删除时清除。
垃圾收集算法
学习了如何判定无效对象、无用类、废弃常量之后,剩余工作就是回收这些垃圾。
常见的垃圾算法有以下几个:
标记-清除算法
标记的过程是:遍历所有的 GC Roots,然后将所有 GC Roots 可达的对象标记为存活的对象。
清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。与此同时,清除哪些被标记过对象的标记,以便下次的垃圾回收。
缺点:
- 效率问题:标记和清除两个过程的效率都不高;
- 空间问题:标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法(新生代)
为了解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾收集时,就将存活者的对象复制到另一块上面,然后将第一块内存全部清除。这种算法有优有劣:
- 优点:不会有内存碎片问题
- 缺点:内存缩小为原来的一半,浪费空间
为了解决空间利用率问题,可以将内存分为三块:Eden、From Survivor、To Survivor,比例是8:1:1,每次使用Eden和其中一块Survivor。回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才使用的Survivor空间。这样只有10%的内存被浪费。
但是我们无法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够,需要依赖其他内存(指老年代)进行分配担保。
分配担保
为对象分配内存空间时,如果Eden+Survivor中空闲区域无法装下该对象,会触发MinorGC进行垃圾收集。但如果Minor GC过后依然有超过10%的对象存活,这样存活的对象直接通过分配担保机制进入老年代,然后再讲新对象存入Eden区。
标记-整理算法(老年代)
标记:它的第一个阶段与标记-清除算法是一模一样的,均是GC Roots,然后将存活的对象标记。
整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才成为整理阶段。
这是一种老年代的垃圾收集算法。老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制存活的对象,效率很低。
分代收集算法
根据对象存活周期的不同,将内存划分为几块。一般是把Java堆分为新生代和老年代,针对各个年代的特点采用最适当的收集算法。
- 新生代:复制算法
- 老年代:标记-清除算法、标记-整理算法
四、HotSpot垃圾收集器
HotSpot 虚拟机提供了多种垃圾收集器,每种收集器都有各自的特点:
新生代垃圾收集器
Serial 垃圾收集器(单线程)
只开启一条 GC 线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程,即 Stop The World。Serial 垃圾收集器适合客户端使用。
ParNew 垃圾收集器(多线程)
ParNew 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。但清理过程依然需要 Stop The World。
ParNew 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。
Parallel Scavenge 垃圾收集器(多线程)
Parallel Scavenge 和 ParNew 一样,都是多线程、新生代垃圾收集器。但是两者有巨大的不同点:
- Parallel Scavenge:追求CPU吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算。
- ParNew:追求降低用户停顿时间,适合交互式应用。
吞吐量 = 运行用户代码时间 +(运行用户代码时间 + 垃圾收集时间)
追求高吞吐量,可通过减少GC执行实际过程的时间,然而,仅仅偶尔运行GC意味着每当GC运行时将有许多工作要做,因为在此期间积累了堆中的对象数量很高。,单个GC需要花更多的时间来完成,从而导致更高的暂停时间。而考虑到低暂停时间,最好频繁运行GC以便更快速完成,反过来有导致吞吐量下降。
- 通过参数 -XX:GCTimeRadio 设置垃圾回收时间占总 CPU 时间的百分比。
- 通道参数 -XX:MaxGCPauseMills设置垃圾处理过程最久停顿时间。
- 通过命令 -XX:+UseAdaptiveSizePolicy 开启自适应策略。我们只要设置好堆的大小和MaxGCPauseMillis或GCTimeRadio,收集器会自动调整新生代的大小、Eden和Survivor的比例、对象进入老年代和年龄,以最大程度上接近我们设置的MaxGCPauseMills或GCTimeRadio。
老年代垃圾收集器
Serial Old 垃圾收集器(单线程)
Serial Old 收集器是 Serial 的老年代版本,都是单线程收集器,只启用一条 GC 线程,都适合客户端应用。它们唯一的区别就是:Serial Old 工作在老年代,使用“标记-整理”算法;Serial 工作在新生代,使用“复制”算法。
Parallel Old 垃圾收集器(多线程)
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
CMS垃圾收集器
CMS(Concurrent Mark Sweep,并发标记清除)收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和GC线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。
- 初始标记:Stop The World,仅使用一条初始标记线程对所有与GC Roots直接关联的对象进行标记。
- 并发标记:使用多条标记线程,与用户线程并发执行。此过程进行可达性分析,标记出所有废弃对象,速度很慢。
- 重新标记:Stop The World,使用多条标记线程并发执行,将刚才并发标记过程中新出现的废弃对象标记出来。
- 并发清除:只使用一条GC线程,与用户线程并发执行,清除刚才标记的对象。这个过程非常耗时。
并发标记与并发清除过程耗时最长,且可以与用户一起工作,因此,总体来说,CMC收集器的内存回收过程是与用户线程一起并发执行的。
CMS的缺点:
- 吞吐量低
- 无法处理浮动垃圾
- 使用“标记-清除”算法产生碎片空间,导致频繁Full GC
对于产生碎片空间问题,可以通过开启 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都会进行一次内存压缩整理,将零散在各处的对象整理到一块。
设置参数-XX:CMSFullGCsBeforeCompaction 告诉 CMS,经过了 N 次 Full GC 之后再进行一次内存整理。
G1通用垃圾收集器
G1是一款面向服务端应用的垃圾收集器,它没有新生代和老年代的概念,而是将堆划分为一块块独立的Region。当要进行垃圾收集时,首先估计每个Region中垃圾的数量,每次都从垃圾回收价值最大的Region开始回收,因此可以获得更大的回收效率。
从整体上看,G1 是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
每个 Region 都有一个 Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 即可防止对整个堆内存进行遍历。
如果不计算维护Remembered Set的操作,G1收集器的工作过程分为以下几个步骤:
- 初始标记:Stop The World,仅使用一条初始标记线程对所以后与GC Roots直接关联的对象进行标记。
- 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢。
- 最终标记:Stop The World,使用多条标记线程并发执行。
- 筛选回收:回收废弃对象,此时也要Stop The World,并使用多条筛选回收线程并发执行。
五、内存分配与回收策略
对象的内存分配,就是堆上分配(也可能经过JIT编译后备拆散为标量类型并间接在栈上分配),对象主要分配在新生代的Eden区上,少数情况下可能直接分配在老年代,分配规则不固定,取决于当前使用的垃圾收集器组合以及相关的参数配置。
以下为普遍的内存分配规则:
- 对象优先在Eden分配
- 大对象直接进入老年代:-XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配
- 长期存活的对象将进入老年代: -XXMaxTenuringThreshold 设置新生代的最大年龄
- 动态对象年龄判定
- 空间分配担保
可能会触发JVM进行Full GC的情况
- System.gc()方法的调用:此方法的调用时建议JVM进行Full GC,注意这只是建议而非一定,但在很多情况下它会触发Full GC,从而增加Full GC的频率。通常情况下我们只需要让虚拟机自己去管理内存即可,我们可以通过 -XX:+ DisableExplicitGC 来禁止调用System.gc()。
- 老年代控件不足:老年代控件不足会触发Full GC操作,若进行该操作后空间依然不足,则会抛出如下处理java.lang.OutOfMemoryError: Java heap space
- 永久代空间不足:JVM规范中运行时数据区域中的方法区,在HotSpot虚拟机中已也称为永久代,永久代可能会被占满,会触发 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出如下错误信息:java.lang.OutOfMemoryError: PermGen space
- CMS GC时出现promotion failed 和 concurrent mode failure promotion failed,就是上文所说的担保失败,而 concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足造成的。
- 统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间。
六、JVM性能调优(后期还得看)
在高性能硬件上部署程序,目前主要有两种方式:
- 通过64位JDK来使用大内存;
- 使用若干个32为虚拟机建立逻辑集群来利用硬件资源。
使用 64 位 JDK 管理大内存
++++
使用 32 位 JVM 建立逻辑集群
++++
调优案例分析与实战
++++
七、类文件结构
JVM的“无关性”
谈论JVM的无关性,主要有以下两个:
- 平台无关性:任何操作系统都能运行Java代码
- 语言无关性:JVM能运行除Java以外的其他代码
Class文件结构
Class文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全都是连续的0/1。
Class文件中的所有内容被分为两种类型:无符号数、表。
- 无符号数:无符号数表示Class文件中的值,这些值没有任何类型,但有不同的长度。u1、u2、u4、u8分别代表1/2/4/8字节的无符号数。
- 表由多个符号数或者其他表作为数据项构成的符合数据类型。
魔数
Class 文件的头 4 个字节称为魔数,用来表示这个 Class 文件的类型。Class 文件的魔数是用 16 进制表示的“CAFE BABE”。
版本信息
紧接着魔数的 4 个字节是版本信息,5-6 字节表示次版本号,7-8 字节表示主版本号,它们表示当前 Class 文件中使用的是哪个版本的 JDK。
高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。
常量池
版本信息之后就是常量池,常量池中存放两种类型的常量:
- 字面值常量:定义的字符串、被final修饰的值
- 符号引用:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符
常量池的特点
- 常量池中常量数量不固定,因此常量池开头放置一个u2类型的无符号数,用于存储当前常量池的容量。
- 常量池的每一项常量都是一个表,表开头的第一位是一个u1类型的标志位(tag),代表当前这个常量属于哪种常量类型。
访问标志
在常量池结束之后,紧接着的两个字节代表访问标志, 这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否被abstract/final修饰。
类索引、父类索引、接口索引集合
类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
字段表集合
字段表集合存储本类涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。
方法表集合
方法表结构与属性表类似。
volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有ACC_VOLATILE和ACC_TRANSIENT标志。
方法表的属性表集合中有一张Code属性表,用于存储当前方法经编译器编译后的字节码指令。
属性表集合
每个属性对应一张属性表,属性表的结构如下:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
八、类加载的时机
类的生命周期
类从被加载到虚拟机内存开始,到卸载出内存位置,它的整个生命周期包括以下7个阶段:
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
验证、准备、解析3个阶段统称为连接。
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始),而解析阶段则不一定:它在某些情况下可以在初始化后再开始,这是为了支持Java语言的运行时绑定。
类加载过程中“初始化”开始的时机
Java虚拟机规范没有强制约束类加载过程的第一阶段(即:加载)什么时候开始,但对于“初始化”阶段,有着严格的规定。有且仅有5种情况必须立即对类进行“初始化”:
- 在遇到new、putstaitc、getstatic、invokestatic字节码指令时,如果类尚未初始化,则需要先触发其初始化。
- 对类进行反射调用时,如果类还没有初始化,则需要先触发其初始化。
- 初始化一个类时,如果其父类还没有初始化,则需要先初始化父类。
- 虚拟机启动时,用于需要指定一个包含main()方法的主类,虚拟机会先初始化这个主类。
- 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类还没初始化,则需要先触发其初始化。
这5种场景中的行为称为对一个类的进行主动引用,除此以外,其它所有引用类的方式都不会触发初始化,称为被动引用。
接口的加载过程
接口加载过程与类加载过程稍有不同。
当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会初始化。
九、类加载的过程
类加载过程包括5个阶段:加载、验证、准备、解析和初始化。
加载
在加载阶段,虚拟机需要完成3件事:
- 通过类的全限定名获取该类的二进制字符流。
- 将二进制字节流所代表的静态结构转换为方法区的运行时数据结构。
- 在内存中创建一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
获取二进制字符流
对于Class文件,虚拟机没有指明要从哪里获取、怎样获取,除了直接从编译的.class文件中读取,还有以下几种方式:
- 从 zip 包中读取,如 jar、war 等;
- 从网络中获取,如 Applet;
- 通过动态代理技术生成代理类的二进制字符流;
- 由JSP文件生成对应的Class类;
- 从数据库中读取,如有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
"非数组类"与"数组类"加载比较
- 非数组类加载阶段可以使用系统提供的引导类加载器,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器控制字节流的获取方式(如重写一个类加载器的loadClass()方法)。
- 数组类本身不通过类加载器创建,它是有Java虚拟机直接创建的,再由类加载器创建数组中的元素类。
注意事项
- 虚拟机规范未规定 Class 对象的存储位置,对于 HotSpot 虚拟机而言,Class 对象比较特殊,它虽然是对象,但存放在方法区中。
- 加载阶段与连接阶段的部分内容交叉进行,加载阶段尚未完成,连接阶段可能已经开始了。但这两个阶段的开始时间仍然保持着固定的先后顺序。
验证
验证阶段确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
准备
准备阶段是正式为类变量(或称“静态成员变量”)分配内存并设置初始值的阶段。这些变量(不包括实例变量)所使用的内存都在方法区中进行分配。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化
类初始化阶段是类加载过程的最后一步,是执行类构造器<clinit>()方法的过程。
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。
<clinit>()方法不需要显式调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
由于父类的<clinit>()方法先执行,意味着父类中定义的静态语句块要优先与子类的变量赋值操作。
<clinit>() 方法不是必需的,如果一个类没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
接口中不能使用静态代码块,但接口也需要通过<clinit>()方法为接口中定义的静态成员变量显式初始化。但接口与类不同,接口的<clinit>()方法不需要先执行父类的<clinit>()方法,只有当父接口中定义的变量使用的,父接口才会初始化。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正常加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法。
十、类加载器
类与类加载器
判断类是否"相等"
任意一个类,都由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。
因此,比较两个类是否“相等”,只有在这个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只有加载它们的类加载器不同,那么这两个类就必定不相等。
这里的“相等”,包括代表类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。
加载器种类
- 启动类加载器(Bootstrap ClassLoader):负责将存放在<JAVA_HOME>\lib目录中的,并且能被虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。
- 扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录中的所有类库,开发者可以直接使用扩展类加载器。
- 应用程序类加载器(Application ClassLoader):由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为“系统类加载器”。它负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
当然,如果有必要,还可以加入自己定义的类加载器。
双亲委派模型
什么是双亲委派模型
双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。(父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码)
工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给辅流加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(找不到所需的类)时,子加载器才会尝试自己去加载。
在java.lang.ClassLoader中的loadClass方法中实现该过程。
为什么使用双亲委派模型
像java.lang.Object这些存放在rt.jar中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而给使得不同加载器加载的Object类都是同一个。
相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个成为java.lang.Object的类,并放在classpath下,那么系统将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证。
参考
- JVM相关
- JVM 底层原理最全知识总结
- ♥JVM相关知识体系详解♥