注:
此篇文章可以算是读《深入理解Java虚拟机:JVM高级特性与最佳实践》一书后的笔记总结加上我个人的心得看法。
整体总结顺序沿用了书中顺序,但多处章节用自己的话或直白或扩展的进行了重新的理解总结,而非单纯摘录。
Java内存区域简介
运行时数据区域
程序计数器
又称“PC”。是一块很小的内存空间。
jvm最终会将java文件编译成字节码指令,通过字节码指令来执行程序。
而程序计数器的作用就是指明“当前线程需要执行的字节码指令”。
程序开始执行前,程序计数器的值,对应的就是“第一条字节码指令”,
当第一条字节码指令执行完毕后,“字节码解释器”会改变程序计数器的值,使其对应下一条要执行的字节码指令。
处理器就是根据“程序计数器的值”来决定当前执行那一条“字节码指令”(即:程序计数器决定了字节码指令的执行顺序)。
在多线程的环境下,为了线程切换后也能恢复到每条线程的字节码的正常执行位置,
所以每条线程都有一个独立的程序计数器(线程私有的内存空间)。
此内存区域是jvm规范中唯一没有规定任何OutOfMemoryError情况的区域。
虚拟机栈
线程私有,生命周期与线程相同。
栈是描述“java方法执行”的内存模型。
栈中存储的单位是“栈帧”,一个方法对应一个栈帧,也可以说是以帧为单位保存当前线程的运行状态。
栈顶的栈帧被称为“当前栈帧”,当一个线程执行一个方法时,jvm就会往该线程对应的栈中压入一个栈帧,这个栈顶栈帧自然就是当前栈帧。
栈帧大致由三部分构成:局部变量区、操作数栈
局部变量表
是一组变量值的存储空间,里面存放了栈帧对应的方法的参数和内部定义的局部变量、还有“对象的引用”。
其中long和double会占用2个局部变量空间,其余的数据类型只占一个。
局部变量所需的内存空间在编译时就会分配完成。
操作数栈
可以将操作数栈看成“临时存储区域”。
操作数栈中存储的数据和“局部变量表”中存储的数据相同。
操作数栈的操作方式顾名思义,是通过将数据压栈和将数据弹栈来操作的。
之所以将其称为“临时存储区域”是因为:
虚拟机的解释执行引擎会将操作数栈作为它的“工作区”,如下图:
解释执行引擎的前两条字节码指令先依次将需要操作的数据压入“操作数栈”,
之后再由指令将其依次弹出并相加,再将结果再压入栈中,
最后再通过指令,将“操作数栈”中的结果弹出并装入“局部变量表”。
由此可见,操作数栈是作为一个“临时的存储区域”而存在的,也是一个临时的工作区。
本地方法栈
与虚拟机栈的作用类似,它们之间的区别是虚拟机栈为虚拟机执行字节码服务,而本地方法栈为虚拟机使用“系统方法”的服务。
java堆
java堆是所有线程共享的区域。
此区域的唯一目的就是存放对象实例以及数组。但对象并不一定都得存在堆上。
java堆是垃圾收集器管理的主要区域,java堆可分为:新生代和老年代。
虽然是线程共有的,但是可以将堆划分成多个线程私有的分配缓存区。
不需要连续的内存空间。
方法区
方法区是所有线程共享的区域。
方法区用来存储已被虚拟机加载的:类信息、常量、静态变量、即时编译器编译后的代码。
方法区主要存储的都是一些长期存在,不易回收的数据。
不需要连续的内存空间。
方法区可以理解成class文件在内存中的存放位置。
而class文件除了各种描述信息以外,还有一项信息就是:常量池。
常量池
常量池是方法区的一部分。
常量池分为两种形态:静态常量池和运行时常量池。
静态常量池:
即每一个.class文件的常量池,静态常量池中包含了:字符串的字面量,类、方法的信息,
占用了class文件的绝大部分空间。
运行时常量池:
运行时常量池存在于内存中,
是“静态常量池加载到内存之后的版本”。
其存储的常量并不一定是在编译时产生的,在程序运行期间产生的常量也存入运行常量池。(如String类的intern()方法)
class文件中有一部分信息,如字符串的字面量等各种字面量、符号引用,
对象
对象的创建简单流程
当虚拟机在解析时遇到一个new指令,
1、找到类
首先在当前class文件的常量池中查找对应类的符号引用。
再检查该符号引用是否已被加载、解析、初始化。如果没有,就必须进行相应的类加载过程。
2、分配内存
接下来虚拟机需为新生对象分配内存(所需内存大小在类加载时就已确定)。
3、为内存初始化“零”值
内存分配完后,jvm会将刚刚分配的内存空间中的数据类型赋“零”值。
(如定义了一个属性:String s,并没有赋值,为了保证该变量能正常使用,在该处会为其赋一个null)
每种数据类型都有自己的“零”值,具体数据类型对应的“零”值如下:
数据类型 |
“零”值 |
int |
0 |
long |
0L |
short |
(short)0 |
char |
'\u0000' |
byte |
(byte)0 |
boolean |
false |
float |
0.0f |
double |
0.0d |
reference |
null |
4、设置对象头
对象头中存储了对象的“自身运行时数据(hash码、GC年龄等)”和“指向“所属类”的指针”。
5、执行对象的构造方法。
将对象装入栈,然后执行构造方法的字节码指令。
(关于这个“这个执行构造方法”,
之前赋“零”值的时候,对象中的每个数据类型都是其特有“零”值,
现在构造方法会先对其赋“正在定义的值”(如对象中有int i = 666,之前内存初始化时,i赋值为0,此时会把i赋值为666),
然后再执行具体的构造方法。)
对象所需内存空间的分配方式
分配方式有两种:
指针碰撞
如果内存为规整的,
已用的内存在一边,空闲的内存再另一边,他们之间放着一个分界点指示器。
那么分配内存就仅仅只是把指示器向空闲区域移动一段与对象大小相等的距离距离即可。
空闲列表
如果内存不规整,
那么虚拟机需要维护一张“记录表”,上面记录了那一块内存区域是可用的。
在分配内存时,只需要按照表上的地址找到内存区域后,再更新表即可。
内存是否规整与垃圾收集齐的GC规则来决定。
为了保证线程安全(即防止:在给a对象分配内存,指示器还没来得及修改,就又需要给b对象分配内存的情况)
目前有两种方案解决:
1、JVM采用CAS配上“失败重试”的方法。
2、把内存提前划分成多个小块给线程,每一小块都相当于是线程的私有内存。称之为“本地线程分配缓冲”。
对象在内存中的布局
每个对象的布局可分为以下三个区域
对象头
包含两部分:
1、对象hash码、对象分代年龄、锁状态标志、线程持有的锁、偏向线程id、偏向时间戳等。
这部分属于对象运行时会产生的数据。
2、类型指针,指向对象的类元数据(该指针并不是必须存在的,即查找对象的类元数据并不一定要经过对象本身)。
3、(只有对象为数组时才有此项)对象头中还有一块用于记录数组长度的数据。
实例数据
程序代码中定义的各种类型的字段的内容,无论是父类中继承的,还是子类定义的。
存储顺序由虚拟机的分配策略决定(HotSpot默认策略为:long/doubles、inits、shorts/chars、bytes/booleans、oops)
对齐填充
此部分不是必然存在,仅仅起到占位符的作用,占到8字节的整数倍。
对象与对应类元数据的访问定位
我们通过栈上的“对象的引用(reference)”找到堆中的对象,然后操作它。
目前通过reference定位对象的方式有两种
句柄访问
java堆中会划分出一块内存作为“句柄池”,每一个reference对应一个句柄,而句柄中包含了“到对象实例的指针”和“到方法区中类元数据的指针”。
reference通过找到对应句柄可以直接找到对象或类。
这种访问方式的好处是“稳定”,对象移动时,只需要改变句柄即可,不需要改变reference
直接指针
reference存储的直接就是对象地址,而对象的类元数据地址指针存储在对象头中。
好处是速度快(因为通过reference可以直接找到堆中的对象)。
垃圾收集器
判断对象是否存活算法
引用计数算法
当对象每一次被引用计数器就+1,
当引用时效(如将引用变量赋值成null,或“含有该引用变量的栈帧出栈”)
当引用计数器为0时,就将其回收。
最主要的缺点就是不能解决对象之间的循环引用问题。
可达性分析算法
JVM从几块区域的“根引用”(GC Roots)处开始分析。
即:从一个引用处,查找它所引用的对象,再看该对象有没有引用其他对象·····
如果整个程序中所有的“根引用”(GC Roots)都顺着找完了,发现有些对象不在这些“引用链”上,
就回收这些对象。
可以作为“根引用”(GC Roots)的引用包括:
1、JVM栈的本地变量表中的引用。
2、方法区中类的静态属性引用。
3、方法区中的常量引用。
4、本地方法栈中的JNI(Native方法)引用。
如果对象在进行可达性分析后发现没有与GCRoots链相连,
那它会被“第一次标记”并进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法(对象没有覆盖finalize()方法或该对象的finalize()方法已经被执行过一次了(一个对象的finalize()方法只会被系统调用一次),都表示没必要执行finalize()方法)。
如果被判定有必要执行finalize()方法,则不会立即回收该对象,而是将其放置在一个叫“F-Queue”的队列中,
然后jvm会用一条自建线程去启动队列中的finalize()方法,
最后jvm会对队列里的对象进行“第二次标记”并进行筛选。
jvm用一条自建的低优先级的线程去触发该finalize()方法时,如果此finalize()中将该对象重新与引用链关联,则后面进行“第二次标记时”将其移除“即将回收的集合”(不被回收),
否则第二次筛选标记时会被回收。
“F队列”中对象的finalize()方法不保证一定会被“执行完毕”(害怕finalize执行缓慢,导致f队列中的其他对象一直等待)。
finalize()运行代价高昂,不确定性大,不建议使用。
引用的强度分类
强引用(Strong Reference)
new出来的对象都是强引用,只要对象存在强引用,就不会被回收。
写法:Object obj = new Object();
软引用(Soft Reference)
通常描述一些“有用,但非必须的对象”,
当内存不足时,会将这些对象列入回收范围,进行二次回收。如果这次回收还是没有足够的内存,才会抛内存溢出异常。
写法:SoftReference<T> sRefer = new SoftReference (t);
通过get方法获得强引用:T t= sRefer.get();
弱引用(Weak Reference)
也是用来描述非必须对象,但强度比软引用更弱。
弱引用引用的对象只能活到下一次GC发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉弱引用引用的对象。
写法:WeakReference<T>weakReference=new WeakReference<T>(t);
虚引用(Phantom Reference)
这是最弱的一种引用关系。
虚引用无法影响到对象的生存时间(即:就算加上虚引用,jvm也不会将其视为对象的引用),也无法通过虚引用获取到一个对象实例。
虚引用唯一的目的就是:当被虚引用引用的对象被回收时,可以收到一个系统通知,表示该对象被回收了。
回收方法区
之前说过了,方法区中存储的都是常量或类的元数据,所以方法区的回收也是针对这两种数据的回收。
废弃常量:假如没有任何对象引用该常量,就回收它。
无用的类:回收类需要类满足以下三个条件;
1、该类的所有实例都已被回收(即不存在)
2、加载该类的classloader已经被回收
3、该类的java.lang.Class对象没有被引用(即任何地方都没有通过反射来获取该类)
垃圾收集算法
标记-清除算法
如同名字,显示标记出需要回收的对象,然后统一回收。
这是最基本的GC算法,
主要有两个不足:
1、效率不高
2、会产生大量不连续的内存碎片。
复制算法
为解决上面提到的“效率问题”,出现了“复制算法”。
目前商业虚拟机采用的方式为:
将内存分为三块:一块较大的Eden空间(占80%)和两块较小的Survivor空间(各占10%),
由于新生代中的对象98%都是很快就消亡的,所以每一次回收都能回收掉几乎98%的空间。
由此,我们每一次使用Eden空间和一块Survivor空间,然后回收时,将其中存活的对象复制到“未被使用”的Survivor空间中,再清除刚刚的Eden空间和刚刚用的Survivor空间。
(相当于:由于每次回收时只有少量对象能存活,所以每次使用内存的90%空间来存对象,然后需要回收时,把里面存活的少量对象复制到没被用的10%的空间中,然后整体清空那90%空间)
当然,并不一定保证存活的对象所需空间小于survivor那10%的空间。
当预留的10%空间不够时,需要依赖老年代进行“分配担保”。
所谓“分配担保”就是将survivor区无法容纳的对象“直接晋升为老年代”。
这样的话就需要老年代中有足够的空间可以容纳这些多出来的存活对象。
老年代需要做以下处理,计算“当前老年代中有没有足够的空间能容纳这些对象”:
如果之前也发生这种“晋升”情况,那么此时老年代会把之前每次晋升时需要的空间算一个“平均值”,与此时老年代拥有的空闲空间做一个比较,来决定是否需要让老年代“腾出更多的空间来装对象”。
标记-整理算法
刚刚提到的“复制算法”明显不适用于“老年代”,因为老年代中的对象都是长时间存在的,一次GC会活下来大部分,使用“复制算法”得不偿失。
所以就有了“标记-整理算法”。
此算法一开始也是标记出所有需要回收的对象。
之后让所有存活的对象都向一边移动,最后清理掉端边界以外的内存。
优点是不会产生内存碎片。
缺点就是存活对象的移动会降低GC效率。
GC算法实现
枚举根节点
之前说过了,如果要回收某一个对象,就得判断该对象是否被引用,而判断的方式一般又选择“可达性分析算法”。
这里就牵扯到了两个问题:
1、通过之前我们对可达性算法的分析可知,在算法进行时(从GCRoots们开始遍历查找时),对象之间的引用关系是不能改变的。
所以在GC时必须停顿所有线程。
2、每一次GC都要从所有根引用(GC Roots)开始遍历太慢了,所以有一个类似于Map的数据结构“OopMap”存储了:“栈或方法区上哪些地方对对象进行了引用”,jvm可根据这个map表直接发现 那个对象在哪里被引用了,从而回收掉剩余的对象。
安全点
但接下来就有一个问题:“几乎每时每刻对象之间的引用关系随着程序的进行都在发生变化”。也就是说OopMap的内容每时每刻都在变化,如果随时都维护着一张OopMap代价太大了。
所以只在某些特定的地方记录当前程序的OopMap。我们称这种地方为“安全点”,当程序运行到安全点时,虚拟机再进行可达性分析然后GC。
安全点主要在:
1、循环的末尾。
2、方法return之前、调用方法之后。
3、可能抛出异常的位置。
假如是多线程的环境,那么其中一条线程到达安全点后,必须使每条线程都到达它所在的安全点后,整个系统才能开始GC。
(因为一条线程如果在可达性分析时,其他线程依旧有可能改变该线程的引用关系,所以GC必须所有线程都停)
目前有两种方式使得所有线程都跑到安全点:
1、抢先式中断:
当某一条线程到安全点后,就表示需要GC了。此时直接中断剩余的所有线程。
如果发现有线程不在安全点上,就恢复该线程,让它跑到安全点上。保证线程都到安全点后就开始GC。
现在几乎没有虚拟机采用这种模式了。
2、主动式中断:
每一个线程在执行时都时刻检查当前位置是不是安全点。如果是安全点的话,就先中断该线程,然后挂起,
等到所有线程都跑到安全点后,就开始GC。
安全区域
上面的主动式中断其实有个bug,
这个漏洞就是“必须所有线程都走到安全点上才能GC”,可假如有某条线程暂时不执行呢?(如sleep)那么该线程永远无法到安全点,其他线程也都没法GC。
对于这种情况就需要“安全区域”来解决。
安全区域是指“在一段代码片段中,引用关系不会发生变化”,这个区域的任何地方GC都是安全的。
所以如果某条线程处于sleep状态,他也就处于了安全区域,随时都能GC。
垃圾收集器
1、Serial收集器
新生代使用,采用复制算法。
单线程,在GC时必须暂停所有工作线程,知道收集结束。
但在客户端的java程序上很有效(因为客户端产品多线程需求不大)
2、ParNew收集器
新生代使用,采用复制算法。
多线程,默认开启的线程收集数与cpu数量相同。
除了Serial外,只有该收集器可以和老年代的CMS收集器合作。所以为java服务端的首选新生代收集器。
3、Parallel Scavenge收集器
新生代使用,采用复制算法。
多线程。
该收集器的特点是:“可以控制吞吐量”
即吞吐量 = 运行用户代码的时间/(运行用户代码的时间+垃圾收集的时间)
也就是:吞吐量 = 程序运行时间/总时间
该收集器适合运行“在后台运算而不需要太多交互的任务”
4、Serial Old收集器
老年代使用,采用标记整理算法。
单线程。
该收集器的目的也是给客户端的java程序使用。
5、Parallel Old收集器
老年代使用,采用标记整理算法。
多线程。
可以与Parallel Scavenge收集器相组合。
6、CMS收集器
老年代使用,采用标记清除算法。
多线程。
GC回收可与用户线程一起进行。
但也有缺点:
1、虽然可与用户线程一起执行,但GC时会占用cpu,导致程序变慢。
2、在CMS GC时也会产生垃圾,这些垃圾没法被回收,只能留到下一次回收。
3、由于是基于标记清除算法,必然会造成内存碎片。碎片太多,大对象分配内存会空间不够,导致提前GC(为了给大对象腾位置)。
7、G1收集器
最先进的收集器。
新生代和老年代都能使用。
GC时可与程序并发运行。
会空间整理,不会产生碎片。
可以预测停顿。
类文件结构
class文件没有任何分隔符,所以所有数据项的大小与顺序都被严格规定了。
其具体的数据项顺序如下:
魔数(magic)
数量:1
大小:4字节
解析:确定这个文件是否能被jvm接收。
由于文件的扩展名可以随意改动,出于安全考虑,通过魔数来决定改文件是否能被jvm接收,而不是扩展名。
class文件次版本号(minor_version)
数量:1
大小:2字节
class文件主版本号(major_version)
数量:1
大小:2字节
解析:java版本号其实是用45开始的,每次主版本更新都加一,如1.7对应的就是51
常量池计数值(constant_pool_count)
数量:1
大小:2字节
解析:指明常量池中“常量的个数”。(即,常量表的个数)
注:是从1开始的而不是从0开始。所以假设值为6,则意味着常量池中有5个常量。
常量(池)(constant_pool)
数量:constant_pool_count-1
大小:不定,
解析:
整个常量池相当于这个类的“资源仓库”。之后的各项目会经常用到常量池中的常量。
这部分由多个常量“表”构成,“每一个常量都是一张表”(即该class中有多少常量,在此处就有多少张常量表。一个常量表只代表一个常量),每种表都有自己的结构。
各个常量表再常量池中的出现顺序没有硬性的要求。
常量池中主要存放了两大类常量
字面量:如字符串、声明为final的常量值等。
符号引用:类或接口的名称(是全限定名称,包括类之前的包名等)。属性的名称和描述符。方法的名称和描述符。
注意:符号引用最后基本上都是指向某个“字面量”!
字面量常量表:CONSTANT_Utf8_info(代表一个utf8编码的字符串),偏移地址为0x0002,值为"fun1"。
符号引用常量表:CONSTANT_NameAndType_info(方法或属性的名称和描述符的符号引用),该表中的“名称index”项的值就是“0x0002”,表示指向上面那个字面量常量表。
(这里多说一下这个符号引用,
jvm在将java代码编译成class文件时,class文件中不会保存各个方法和属性的“真正内存布局”,连指向这些方法的“真正存在地址”的直接引用都不会存在。
只会有一个符号引用(可以理解为就是一个占位符),等到“加载类的时候”,才会把这个“符号引用”变成“直接引用”,然后可以通过这个直接引用来找到指定的方法或属性)。
常量池中可以存放的常量种类一共有14种。
就像之前说的,每一种常量都是一张“有着独特结构的表”。
不过每一张表的“第一个字节”都是一个“索引值”(14种表就有14种索引值),表示告诉jvm:“现在开始的是哪一种表”。
然后索引值后面才是该表的具体内容。
我们举上14种常量的三种的表结构来看一下:(再说明一下,各 个常量表在class文件中的出现顺序与索引值的顺序)
CONSTANT_Integer_info
tag(数量:1):索引值,为3
bytes(数量:1):存储int的值,该项占4个字节
CONSTANT_Class_info
tag(数量:1):索引值,为7
index(数量:1):指向“全限定名”常量索引,即指向的是”该常量池中的某一个CONSTANT_Utf8_info“
这就是一个符号引用。
类似的还有:CONSTANT_Methodref_info(方法的符号引用)等各种符号引用。
CONSTANT_Utf8_info
tag(数量:1):索引值,为1
length(数量:1):该utf8编码的字符串长度(单位是字节),“也用来定义接下来byte的数量”
bytes(数量:length):字节长度有多少,就有多少个该项,该项就是该字符串的具体字节。
“在常量池中所有的字符串都是这么存储的”
(如:
假设某个类的全限定名为:org/fenixsoft/clazz/TestClass
那么这个字符串“org/fenixsoft/clazz/TestClass”就是存储在常量池中的某个CONSTANT_Utf8_info中。
而该类的索引CONSTANT_Class_info中的index,指向这个CONSTANT_Utf8_info)。
访问标志
常量池后面紧跟的两个字节就是“访问标志”。
用来识别当前的类文件是类还是接口;是否是public;是否被申明成final等。
类索引
类索引用于确定当前类的“全限定名”。
索引的值是“常量池中对应的类常量项的偏移地址”
(如该类的常量项“在常量池中”的偏移地址为0x0001,
那么此处的值也是0x0001)
通过类索引查找类的全限定名
(父类和接口的全限定名也是这么找的。
根据偏移地址,
先找到该类在“常量池"中的常量项(CONSTANT_Class_info表),
再通过该常量项的index,找到对应的字符串常量项(CONSTANT_Utf8_info)的所在位置,
根据字符串常量项中的bytes,就能知道该类的全限定名字符串是什么了。)
父类索引
用于确定当前类的“父类的全限定名”。
(由于java是单继承,所以当前类的直接父类只会有一个)。
该索引的值是“常量池中对应的类常量项的偏移地址”,即CONSTANT_Class_info表。
接口计数器
由于一个类可以实现好多个接口,所以需要先用该值说明接口数量。
接口索引
接口索引的数量由上面的接口计数器决定。
每个接口索引的值是“常量池中对应的类常量项的偏移地址”,即CONSTANT_Class_info表。
类属性数量
表示类中有多少属性。
类属性表集合
类中每一个属性就是一张表,表的数量由之前的计数器来定。
注:此处的“属性”指的不是我们常称的属性(即类中变量),而是类的“信息”。
这些信息包括:内部类的列表、方法的局部变量描述、源文件的名称等信息。
每个属性的名称都需要从常量池中引用一个“CONSTANT_Utif8_info”类型的常量来表示。
Code属性表
java程序的方法体代码经过编译后,最终变成“字节码指令”,然后也存放到类的属性表之一“Code表”中。
(接口中就没有Code表,因为没有方法体)
Code表中除了该类中方法体对应的字节码外,还有一个“异常表”。
异常表中一共有四个字段:start_pc、end_pc、handler_pc、catch_type。
当start_pc行到end_pc行之间出现了catch_type或其子类的异常时,则跳转到第handler_pc行继续处理。
catch_type为0时,则任何异常都要跳转到handler_pc行处理。
Exceptions属性
列举出类的所有方法中可能抛出的异常。
SourceFile属性
用于记录生成这个Class文件的源码文件名称。
ConstantVavlue属性
该属性作用是通知虚拟机“自动为静态变量赋值”。
即,只有static类型的变量才有这个属性。
之前提过,非static变量的初始化是在<init>方法中进行(也就是对象加载的最后一步,调用构造函数)
而static类型的变量的初始化有两种方式:
1、在类加载时,由类构造器<clinit>初始化。
2、给该变量赋ConstantValue属性的值。
InnerClasses属性
如果一个类中定义了内部类,则编译器会为该类生成InnerClasses属性。
“类加载”机制
类“初始化”时机
类从加载到出内存,一共要经历以下七个阶段:
加载、验证、准备、解析、初始化、使用、卸载。
(其中,验证,准备,解析,这三个阶段又统称为“连接”)
类的加载、验证、准备、解析要在“初始化之前完成”。
jvm没有强制规定什么时候开始加载。
但强制规定了类初始化的情况只有以下5种:
1、遇到相关字节码指令时(如果该类没初始化过,则初始化),即以下三种情况:
(1)、使用new关键字实例化对象时。
注:new一个数组时不会触发初始化。
(2)、读取一个类的静态属性字段时(被final修饰的静态属性除外,因为final常量在编译时期已把数据放入常量池)。
注:只有“直接定义”该静态属性的类才会被初始化。如:B类中有一个静态属性b字段,A类继承B类。调用A.b,只有B类会被初始化(因为,虽然是通过A类来调用的静态字段,但B类才是“直接定义”该字段的类,所以只有B类会被初始化)。
1、调用一个类的静态方法时。
2、使用java.lang.reflect包的方法对类进行反射调用时。(如果该类没初始化过,则初始化)
3、初始化一个类时,如果其父类没有初始化,则先初始化其父类。
4、jvm启动时,用户需要先指定一个主类(main方法那个类),jvm先初始化该类。
5、当时用java7的动态语言支持时。(如果该类没初始化过,则初始化)
接口的加载与类的加载类似,区别只有一个在一个接口初始化时,并不需要全部初始化完所有父类接口。
“类加载”全过程
加载
主要就做了3件事:
1、通过一个类的全限定名来获取此类的二进制流(关于获取类的全限定名的方法可以看之前讲解的常量池章)。
(此处并没有指定二进制流必须从哪获取,可以从class文件获取,也可以运行时计算生成等)。
2、把该字节流转化成方法区运行时的数据结构。
3、生成一个代表该类的Class类型对象,作为访问该类在方法区中数据的入口。
(关于这个对象的存放地点有点特殊,虽然是对象,但不一定是存放在堆中,如HotSpot虚拟机就是把Class对象存放在方法区中)
数组类的加载过程有所不同。
数组类不是通过类加载器加载的,而是通过jvm直接创建的。
数组类的创建需要遵守以下规则:
1、如果数组里面的组件是“引用类型(非基本数据类型,即类)”,则使用递归加载里面的每一个类。
2、如果数组里面的组件不是引用类型(如int[]),则直接将该数组与“Bootstrap加载器”关联(关于bootstrap后面会详讲)。
3、数组类的可见性与其组件类的可见性一致。
注意:加载阶段与连接阶段(验证+准备+解析)是“交叉进行”的,即加载可能还未完成,连接就已开始。
验证(连接阶段之一)
该阶段的目的是:确保Class文件的字节流中包含的信息符合当前jvm的要求,并且确保字节流不会危害到jvm。
验证大致分一下四个阶段:
1、文件格式验证
这一阶段验证的是字节流,主要验证字节流是否符合Class文件的规范,并且能不能被当前版本的jvm处理。
(如:开头魔数是否正确、主次版本号是否在当前虚拟机能处理的范围之内等)
通过了这个验证之后字节流就会存入内存中的方法区,“之后就不会再操作该字节流了”
2、元数据验证
对该类的元数据进行验证,如该类的继承是否正确、该类是否实现了其接口中的方法等。
3、字节码验证
对该类中的各个“字节码指令”进行验证,如保证类型转换的正确性、保证跳转命令的正确性等。
4、符号引用验证
该验证发生在“解析阶段”中,当jvm把符号引用转化为直接引用时,
符号引用验证的目的自然就是“保证转化动作能正常进行”。
如,验证能不能通过符号引用对应的全限定名找到对应的类、符号引用的类的访问性能不能被当前类访问到等。
准备(连接阶段之一)
该阶段作用是:“为该类中的常量、静态变量在方法区中分配内存,并初始化”。
注意:
此时的初始化时为数据类型赋“零值”(与对象加载是变量赋“零值”一样)。
如 public static in value = 123;
此处会将value初始化为0,而不是123.(把value赋值成123要等到“初始化阶段”进行)
String也同理,会先初始化为null,而不是其具体值。
每种数据类型都有自己的“零”值,具体数据类型对应的“零”值如下:
数据类型 |
“零”值 |
int |
0 |
long |
0L |
short |
(short)0 |
char |
'\u0000' |
byte |
(byte)0 |
boolean |
false |
float |
0.0f |
double |
0.0d |
reference |
null |
解析(连接阶段之一)
将常量池中的符号引用替换成直接引用的过程。
符号引用在之前将常量池中已经说了,它们是以“常量表中的常量的形式出现”(如CONSTANT_Class_info表等)。
直接引用可以使指向目标的指针、相对偏移量、句柄等。
下面分别说一下7中符号引用(也就是常量池中的7种常量)的解析过程:
类或接口的解析
把全限定名传递给类加载器加载(类加载器的加载过程与现在正在进行的“类加载”流程一致)
加载完毕没问题的话,就会正确的加载到方法区中。
于是就有了该类或接口的方法区的指针。
类中属性的解析
通过常量池中“属性常量表CONSTANT_Fieldref_info”可知,
每一个“属性常量表”(CONSTANT_Fieldref_info)中都包含了两个信息:
1、该属性“所在类或接口”的符号引用(也就是CONSTANT_Class_info表)。
2、该属性的名称与修饰符的符号引用(也就是CONSTANT_NameAndType_info表)
可以看出解析类中属性的流程基本就是:
先在方法区中找到该属性所属的类或接口(如果该符号引用还没变成直接引用,就先对其进行解析,如上面一样)。
再根据CONSTANT_NameAndType_info表中信息找到该类常量池中的“具体常量值”(具体找法可以参考之前的常量池介绍),
如果该类中没找到,就递归搜索父类接口中的常量池,
如果父类接口的常量池中也没有,就递归搜索父类中的常量池。
否则抛异常。
类中方法的解析
解析方法与“类中属性”的解析方式一样,
原因是:
常量池中,每一个“方法常量表”(CONSTANT_Methodref_info)中包含的还是上面那两项,
即该方法所在类或接口的符号引用(CONSTANT_Class_info表)、和该方法的名称与修饰符表的符号引用(CONSTANT_NameAndType_info表)。
所以加载方式还是上面那一套。
接口方法的解析
接口方法常量表为CONSTANT_InterfaceMethodref_info,
里面还是上面那两项,
所以加载步骤还是,先找所属类,再找具体方法。
初始化
初始化阶段就是执行“类构造器方法(<clinit>方法)”的过程。
“类构造器方法”与对象的“构造方法”是不一样的。
之前将对象的创建流程时,提到,对象创建的最后一部也是初始化,即“运行构造方法”,构造方法会先“给类的“非静态,非final”属性赋具体的值”(之前是零值),再执行自定义的构造方法。
而类构造器方法也是这样,目的是“给静态变量,或静态语句块中的变量赋具体值”。(之前在“准备阶段”时,这些静态属性都被赋了“零”值)。
类加载器
刚刚在将“类加载”的“加载”阶段时说说了,jvm并没有限定“如何通过“类的全限定名”来获取此类的二进制字节流”,
以便让程序可以自己决定“如何获取类的二进制流”,而实现这个功能的代码模块称为“类加载器”。
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载时才有意义。否则这两个类就是不相等的。
从jvm角度来讲,只有两种类加载器:
1、启动类加载器(Bootstrap加载器),由c++实现,是jvm的一部分。
2、其他类的加载器,由java语言实现,在jvm外部,这些类都“继承于ClassLoader”
类加载器的执行有先后顺序,依次为:
启动类加载器(Bootstrap ClassLoader)
该加载器负责加载jvm所需的系统级别类,如java.lang.String, java.lang.Object等等。
Bootstrap Classloader会读取 {JRE_HOME}/lib 下的jar包和配置,然后将这些系统类加载到“方法区”内。
扩展类加载器(Extension ClassLoader)
由“sun.misc.Launcher.ExtClassLoader”类实现。
它负责加载JRE的扩展目录(JAVA_HOME/jre/lib/ext或者由java.ext.dirs系统属性指定的)中JAR的类包。
应用程序类加载器(Application ClassLoader)
由“sun.misc.Launcher.AppClassLoader”类实现。
作为程序中的默认“类加载器”。
我们自己创建的各种class,都是通过该加载器加载的。
自定义类加载器
用户可以自定义类加载器,但要继承java.lang.ClassLoader类。
然后可以指定使用。
双亲委派模型
所谓双亲委派模型就是指:除了Bootstrap加载器外,其余的类加载器都应当有自己的父类加载器(此处的父子关系一般指的不是继承关系,而是通过组合关系“复用“父类”的代码”)。
整个过程为:
当某个类加载器收到类加载请求时,它自己不会去尝试加载该类,而是把请求交给“父类”加载器。
当“父类”加载器反馈说不能加载时,子类再加载。
好处是:
之前我们提到了,“任意的两个类,只要不是同一个类加载器加载的,这两个类就被判定为不相同”。
我们举一个例子,假设Object类由Bootstrap加载器加载了,如果没有双亲委派机制,我们又让一个底层加载器再加载一个Object类,这样就会出现两个Object类,这个应用程序都有可能混乱。
而使用了双亲委派机制后,对于Object类来说,无论何时用哪一个类加载器来加载,最终都会送到最上层,让Bootstrap加载器来加载,也就没有上面那个问题了。
字节码执行引擎
运行时栈帧结构
每一个方法从调用开始至执行完成的过程,都对应一个“栈帧”在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包含了以下各项,在编译时期栈帧里面的结构就已经确定了。
局部变量表
顾名思义,局部变量表是“变量值的存储空间”,存放的是“方法参数”和“方法内定义的局部变量”。
局部变量表的容量的最小单位是“变量槽(Slot)”。
每个Slot占用32位长度内存空间。
所以一个Slot中可以存放boolean、byte、char、short、int、float、reference、returnAdress八种数据类型的数据。
而64位的数据只有long和double两种。此处采用的存储方法类似于“把一个long或double读写分割为两次32位读写”(因为栈是线程私有,所以不需要考虑线程安全问题)。
为了节省栈帧空间,局部变量表中的slot是可以“重用”的。
即:
“某方法体的作用域中定义了一个变量,即使程序运行出了该变量的作用域,该变量依旧会存在(即不会被gc回收),而有新的变量需要存储到slot时,才会更新覆盖掉旧slot数据”。
还有一个细节就是:局部变量表中的变量没有赋“零值”的操作。
之前的类初始化和对象初始化都会有数据赋“零”值的阶段。
而局部变量表中的变量不存在这种赋“零”值的阶段。
所以开发者必须给局部变量赋初始值。
操作数栈
在之前介绍java内存区域时较详细的介绍过操作数栈。
操作数栈就是一个临时的工作区。
操作数栈里面的元素来自于“局部变量表”。
也就是说,当局部变量表中的局部变量需要进行算法操作时,就将数据压入操作数栈中然后进行操作。
最后往往会将最终的运算结果先压入操作数栈,然后弹出放回到局部变量表中。
虽然每个栈帧之间是独立的,但往往为了优化,会使两个栈帧“共用一部分操作数栈”,如参数传递时。
这样就不需要额外的参数复制传递了。
动态链接
和“动态链接”相对应的就是“静态解析”。
这两个名词的意义其实都是:“把符号引用转化成直接引用”。
二者的区别是:
静态解析:在类加载阶段和“第一次使用”时进行转化。(具体过程可以参考我上面的“类初始化的解析阶段”)
动态链接:在类的“每一次运行期间”进行转化。(这属于jvm对动态类型语言的支持)
方法调用
方法调用和方法执行是不同的,
方法调用阶段的唯一任务就是“确定具体调用哪一个方法”。
所谓的“确定具体调用哪一个方法”,就是两种方式实现的:静态解析和动态链接。(这两者都可以把符号引用转化成直接引用,也就是确定具体调用哪一个方法)
解析
先说静态解析。
class文件的编译过程,不会将“符号引用”转化成“直接引用”。(也就是说,class文件中不会确定具体的方法调用)
而在类加载阶段才会开始将一部分“符号引用”转化成“直接引用”,而这个过程就叫静态解析。
注意:
能够“静态解析”的方法需要符合“编译期可知,运行期不变”的要求,
符合该要求的方法主要包括两大类:静态方法和私有方法。(当然不止这两大类的方法,下面提到的重载方法等,也符合静态解析的要求)
(换句话说,即静态方法和私有方法是在类加载过程中静态解析的)
因为静态方法和私有方法都不可能通过继承等方式“重写成其他版本”。
分派
之前的静态方法和私有方法全都是静态解析的。
而其余的方法的“符号引用”转化成“直接引用”的过程称之为“分派”。
而分派又分为静态分派(与静态解析一样,是在编译期间“确定”,然后在类加载时执行静态解析),和动态分派(在运行期才能完成解析)
静态分派
静态分派多表现成方法的“重载”。
方法的“重载”就是:某一类中存在多个方法名相同的方法,但具有不同的参数个数或类型,则在调用这种方法时,称之为方法重载。
再将静态分派前,先讲一下以下概念:
Human man = new Man();
其中:
Human是变量的“静态类型”。
Man是变量的“实际类型”。
二者的区别是:
静态类型在“编译期”就已经可知且确定了。
而实际类型需要等到运行期才可确定。
我们再来看“重载方法”的调用过程。
重载方法是根据参数的不同来确定具体调用的是那个重载方法。
而传入参数的类型(也就是静态类型),是在编译期就可知的,
也就是说,在编译期就能够确定“具体调用的是那个重载方法”。
而这种调用过程,我们称之为“静态分派”。
关于重载方法的调用符合静态解析的要求(即“编译期可知,运行期不变”)。
所以重载方法的解析,属于静态解析,在类加载时就会被解析。
动态分派
动态分派多表现为方法的“重写”。
方法的重写就是:子类继承父类,子类重写了父类中的某个方法。
还是先从静态类型和实际类型的角度举个例子:
(以下例子中Man重写和Woman都重写了Human中的sex()方法)
Human human1= new Man();
Human human2= new Woman();
human1.sex();
human2.sex();
通过之前的讲解我们可以知道Human是在编译期可确定的,但这次的sex方法调用明显跟后面的“实际类型”有关。
所以sex()方法的调用“不能在编译期确定调用哪一个方法”。
也就是说,重写方法的调用(动态分派)不符合静态解析的要求。
动态分派的具体过程如下:
在创建对象的最后一个阶段执行,也就是“调用实际的构造方法”阶段。
把实际类型(Man)的直接引用先存放入“局部变量表”。
在从局部变量表中的这些实际类型(Man)的直接引用压入“操作数栈”
然后根据操作数栈中的引用,找到引用所指的实际类型(Man)。
再找该类型(Man)中有没有需要调用的方法(sex()),如果有,则执行,
否则,继续找该类(Man)的父类(Human)中有没有改方法(sex())。
这也就是为什么:
调用某个子类的重写方法时,会执行该子类的方法,而不是父类中的方法。
而通过子类又可以调用其父类的方法。
在实际实现中基于性能考虑,大部分虚拟机都不会真正的进行这种复杂的操作。
最常用的优化手段就是:
在方法区中建立一张“虚方法表”,使用虚方法表索引来代替元数据查找。
虚方法表中存放了各个方法的“实际入口”。
如果某父类方法没有被“重写”过,则该父类与其子类的“虚方发表”中的该方法的实际入口都是“父类的方法”。
这样优化的好处是:
如果方法没被重写,则不需要先查找子类,再查找父类,而是可以直接找到父类中的该方法。