先给大家上一张图,表示JVM中都讲了什么知识,接下来我们再一步步讲:
一.JVM的结构:(再来一张图大家肯定都见过)
类加载器将Class文件读取后,放到运行时数据区,然后执行引擎执行或调用本地接口、本地库。他就被存储在运行时数据区被使用;
下面我们来分别讲讲运行时数据区的各部分结构:
JVM内存模型主要分为两部分:线程共享内存(上图中绿色),线程私有内存(上图中黄色);
1.我们先来说线程共享内存:
1).方法区:
保存着被加载过的每一个类的信息:这些信息由类加载器在加载类的时候,从类的源文件中抽取出来;static变量信息也保存在方法区中。可以看做是将类(Class)的元数据,保存在方法区里。方法区是线程共享的;当有多个线程都用到一个类的时候,而这个类还未被加载,则应该只有一个线程去加载类(所以方法区是线程安全的),让其他线程等待。方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。jvm也可以允许用户和程序指定方法区的初始大小,最小和最大限制。方法区同样存在垃圾收集,因为通过用户定义的类加载器可以动态扩展Java程序,这样可能会导致一些类,不再被使用,变为垃圾。这时候需要进行垃圾清理。
方法区主要包括:类信息(包括类型信息,方法信息,字段信息,指向类加载器的引用,指向Class实例的引用,方法表),常量,静态变量,编译器编译后的代码和运行时常量池(包括一些字面量和符号引用)。
类型信息
包括以下几点:
类的完整名称(比如,java.long.String)
类的直接父类的完整名称
类的直接实现接口的有序列表(因为一个类直接实现的接口可能不止一个,因此放到一个有序表中)
类的修饰符
可以看做是,对一个类进行登记,这个类的名字叫啥,他粑粑是谁、有没有实现接口, 权限是啥;
类型的常量池
每一个Class文件中,都维护着一个常量池(这个保存在类文件里面,不要与方法区的运行时常量池搞混),里面存放着编译时期生成的各种字面值和符号引用;这个常量池的内容,在类加载的时候,被复制到方法区的运行时常量池 ;
字段信息
声明的顺序
修饰符
类型
名字
方法信息
声明的顺序
修饰符
返回值类型
名字
参数列表(有序保存)
异常表(方法抛出的异常)
方法字节码(native、abstract方法除外,)
操作数栈和局部变量表大小
类变量(即static变量)
非final类变量
在java虚拟机使用一个类之前,它必须在方法区中为每个非final类变量分配空间。非final类变量存储在定义它的类中;
final类变量(不存储在这里)
由于final的不可改变性,因此,final类变量的值在编译期间,就被确定了,因此被保存在类的常量池里面,然后在加载类的时候,复制进方法区的运行时常量池里面 ;final类变量存储在运行时常量池里面,每一个使用它的类保存着一个对其的引用;
指向类加载器的引用
jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。
jvm在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。这对jvm区分名字空间的方式是至关重要的。
指向Class实例的引用
jvm为每个加载的类都创建一个java.lang.Class的实例(存储在堆上)。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个Class对象的引用;
方法表(以下是摘抄)
为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,jvm的实现者还可以添加一些其他的数据结构,如方法表。jvm对每个加载的非虚拟类的类型信息中都添加了一个方法表,方法表是一组对类实例方法的直接引用(包括从父类继承的方法。jvm可以通过方法表快速激活实例方法。(译者:这里的方法表与C++中的虚拟函数表一样,但java方法全都 是virtual的,自然也不用虚拟二字了。正像java宣称没有指针了,其实java里全是指针。更安全只是加了更完备的检查机制,但这都是以牺牲效率为代价的,个人认为java的设计者 始终是把安全放在效率之上的,所以java才更适合于网络开发)
附加知识解释:
字面量:比如说int a = 1; 这个1就是字面量。又比如String a = "abc",这个abc就是字面量。
符号引用:符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能够无歧义的定位到目标即可. 例如, 在java中,一个java类将会编译成一个class文件。在编译时,java类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。比如org.simple.People类要引用org.simple.Tool类,在编译时People类并不知道Tool类的实际内存地址,因此只能使用符号org.simple.Tool(假设)来表示Tool类的地址。而在类装载器装载People类时,此时可以通过虚拟机获取Tool类 的实际内存地址,因此便可以既将符号org.simple.Tool替换为Tool类的实际内存地址,及直接引用地址。
直接引用:
(1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
(3)一个能间接定位到目标的句柄
注:https://blog.youkuaiyun.com/maihilton/article/details/81531878
运行时常量池:
是方法区的一部分,class文件除了有类的字段、接口、方法等描述信息之外,还有常量池用于存放编译期间生成的各种字面量和符号引用。
注:https://blog.youkuaiyun.com/w584212179/article/details/91045380
2).堆(这块区域也是线程共享的)
我的理解:堆是JVM中保存对象实例的地方,当你每new一个对象,他就在堆中开辟一块空间用来存放你new的这个对象。堆中的对象生命周期的管理由JVM的垃圾回收机制GC进行回收和统一管理。
Java中分配堆内存是自动初始化的。Java中所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配,也就是说在建立一个对象时从两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。
堆中还被分为新生代和老年代(比例: 新生代:老年代 = 1:2)。
一般对象都在新生代创建,因为Java程序中的对象绝大部分是朝生夕死的,所以新生代中每次GC都会有大量对象被回收,新生代的GC操作也更频繁。
年老代中一般存放那些对象存活率高、生命周期长的对象。一般位于新生代中的对象满足某些条件(比如大对象、经历了几次新生代GC还存活的对象等)会转到年老代中去。年老代中GC不频繁,但GC效率要比新生代中GC慢许多。
下面这张图表示了JVM中堆的结构分布:
堆的结构总体来说分为两部分:一个是新生代,一个是老年代。
新生代中又分为三部分:Eden区(存放新生对象),两个幸存区(From Survivor和To Survivor)(存放每次垃圾回收后存活的对象)。(GC操作比较频繁)
老年代:老年代中一般存放那些对象存活率高、生命周期长的对象。一般位于老年代中的对象满足某些条件(比如大对象、经历了几次新生代GC还存活的对象等)会转到年老代中去。年老代中GC不频繁,但GC效率要比新生代中GC慢许多。
JVM垃圾回收机制采用“分代收集”:新生代采用复制算法,老年代采用标记-清理或标记-整理算法。
Minor GC和Full GC
- Minor GC(新生代GC):指发生在新生代的垃圾回收动作,因为Java对象一般满足朝生夕死的条件,所以Minor GC非常频繁,一般回收速度也比较快。
- Full GC / Major GC(年老代GC):发生在年老代的GC,一般会伴随至少一次的Minor GC。Full GC一般比Minor GC慢10倍以上。
空间分配担保机制
在新生代发生Minor GC前,虚拟机会检测老年代最大可用的连续内存空间是否大于新生代所有的对象的总空间,如果条件成立则Minor GC是安全的。否则判断老年代连续可用内存是否大于历次晋升到老年代对象的平均大小,如果大于则尝试一次Minor GC,小于或者没有设置内存担保机制进行一次Full GC。
所谓的担保就是,当新生代进行Minor GC后仍有大量对象存活的情况下,就需要老年代进行分配担保。所谓的担保就是所有Survivor无法容纳的对象都放入老年代,但是内存回收完成之前无法知道存活的对象数量,就只能按历次晋升到老年的对象平均值作为经验值,从而来决定时候进行Full GC以让老年代腾出更过的空间。担保失败则还是进Full GC,然后会浪费时间,但是大部分情况下担保都是有效的。
内存分配策略:
对象有限在Eden中分配
大多情况下,对象在新生代Eden区中分配。当Eden区中没有足够的空间进行分配的时候,虚拟机将发生一次Minor GC。
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。经常出现大对象容易导致内存还有不少空间的时候提前触发GC。虚拟机提供一个配置参数,可以令所有大于这个值的对象直接在年老代分配。
长期存活的对象进入老年代
JVM为每个对象定义了一个对象年龄(Age)计数器。每次Minor GC结束后对象存活则Age +1,当对象的年龄增加到一定程度后(默认15),将会被晋升到老年代。
动态对象年龄判定
为了能更好地适应不同程序的内存情况,虚拟机提供动态对象年龄判定机制。如果Survivor区(存疑:不知道说的是一个Survivor区域(from区域或to区域),还是说的是from+to区域)中相同年龄所有对象的总和大于等于Survivor空间的一半,那么所有大于等于该年龄的对象直接进入年老代。(存疑)
注:https://blog.youkuaiyun.com/u014493323/article/details/82921740
eg:
场景假设
如果说非得相同年龄所有对象大小总和大于Survivor空间的一半才能晋升。我们看下面的场景
- MaxTenuringThreshold为15
- 年龄1的对象占用了33%
- 年龄2的对象占用33%
- 年龄3的对象占用34%。
结果:总体表征就是,年龄从小到大进行累加,当加入某个年龄段后,累加和超过survivor区域*TargetSurvivorRatio的时候,就从这个年龄段网上的年龄的对象进行晋升。
还是上面的场景。 年龄1的占用了33%,年龄2的占用了33%,累加和超过默认的TargetSurvivorRatio(50%),年龄2和年龄3的对象都要晋升。
Eden、From、To区的介绍:
新生代大小(PSYoungGen total 9216K)=eden大小(eden space 8192K)+1个survivor大小(from space 1024K)
HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8(Eden):1(一个survivor)。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。(猜测:然后进行动态对象年龄判定)经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。(存疑:此时也可以进行动态对象年龄判定)
Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生。
注:
https://blog.youkuaiyun.com/towads/article/details/79784249
https://www.cnblogs.com/duanxz/p/6076662.html
说说垃圾回收算法:
说之前先讲讲如果判断一个对象是垃圾:
引用计数法:
引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时计数器就-1;任何时候计数器为0时,对象就不再使用,可以被回收。
虽然引用计数法实现简单,效率也很高,但是它很难解决对象之间的相互循环引用问题。
举个例子:
分别new 2个实例对象A以及实例对象B,当A持有B的引用,同时B持有A的引用时。就算A、B不再被使用,如果使用用引用计数法标记对象,A、B都不可能被回收了。
可达性分析算法:
可达性分析算法: 通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始往下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,证明这个对象是不可用的。
Java中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- Native方法中JNI引用的对象
对象的自救
在可达性分析算法中不可达的对象,也并不是“非死不可”,真正宣告一个对象死亡,至少要经历2次标记过程:可达性分析过程中发现没有与GC Roots 相连接的引用链,那它会被标记并且进行一次筛选,筛选的条件是否有必须要执行对象的finalize()方法。当对象没有覆写finalize()或者已经执行过finalize()(也就是finalize()最多执行一次)则虚拟机视为“没有必要执行“。
finalize()是对象自救的最后一次机会,只要重新与GC Roots关联上即可。
讲讲四种引用类型:
强引用(Strong Reference)
Java中默认声明的就是强引用,比如:
Object obj = new Object(); //只要obj还指向Object对象,Object对象就不会被回收
obj = null; //手动置null
只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足时,JVM也会直接抛出OutOfMemoryError,不会去回收。如果想中断强引用与对象之间的联系,可以显示的将强引用赋值为null,这样一来,JVM就可以适时的回收对象了
软引用(SoftReference)
在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。这种特性常常被用来实现缓存技术,比如网页缓存,图片缓存等。
在 JDK1.2 之后,用java.lang.ref.SoftReference类来表示软引用。
软引用可以和一个引用队列(ReferenceQueue)联合使用,在软引用所引用的对象被垃圾回收器回收前,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
弱引用(WeakReference)
弱引用与软引用的区别在于:弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,在弱引用所引用的对象被垃圾回收之前,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
jdk也提供了java.util.WeakHashMap这么一个key为弱引用的Map。
虚引用(PhantomReference)
“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
虚引用主要用来跟踪对象被垃圾回收器回收的活动。
又称幽灵引用。
java.lang.ref.PhantomReference 类中只有一个方法 get(),而且几乎没有实现,只是返回 null。而且这个类只有一个构造器 ( 软引用和弱引用均有两个构造器): 也就是说,幽灵引用只能与 ReferenceQueue(后面会提到这个类)一起使用。如果一个对象仅有幽灵引用,那么它就像没有任何引用一样,在任何时候都可能被 gc 回收。
虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
引用队列(ReferenceQueue):就是在对象在被回收之前,告诉你一声。
引用队列可以与软引用、弱引用以及虚引用一起配合使用,当垃圾回收器准备回收一个对象时,如果发现它还有引用,那么就会在回收对象之前,把这个引用加入到与之关联的引用队列中去。程序可以通过判断引用队列中是否已经加入了引用,来判断被引用的对象是否将要被垃圾回收,这样就可以在对象被回收之前采取一些必要的措施。
与软引用、弱引用不同,虚引用必须和引用队列一起使用。
注:https://blog.youkuaiyun.com/mulinsen77/article/details/86560790
好了,回收算法来了:
回收算法主要有,标记-清除法、复制算法、标记-整理法、分代收集算法;
标记-清除算法
标记—清除算法分为“标记”和“清除”两个阶段:首先标记出所需要回收的对象,在标记完成后统一回收所有被标记的对象。
标记-清除算法主要有2个问题:
- 效率问题:标记和清除两个过程效率都不高
- 空间问题:标记清除后产生大量的不连续的内存碎片,空间碎片太多可能会导致分配较大对象时,无法找到足够的联系内存而不得不提前触发另一次GC。
复制算法
复制算法:将可用内存空间分为大小相等的2块,每次只使用其中的一块,当这一块内存用完了,就将还存活的对象复制到另一块上面,把已使用的内存空间清理掉。
复制算法算法的优点是效率高,不用考虑内存碎片问题,但是代价是内存缩小为了原来的一半。
新生代一般用此种算法来收集,具体算法以及其中的内存分配担保机制都在本文稍后解释。
标记-整理算法
标记-整理算法,标记过程与标记-清除算法一致,只是手续步骤不是直接对可回收对象进行整理,而是让所有的存活对象往一端移动,然后直接清理掉边界以外的内存。
分代收集算法
这种算法并没有新的思想,只是根据对象存活的周期的不同将内存划分为几块。一般把Java堆分为新生代和老年代,这样就根据各个年代的特点采用最适当的收集算法。
- 新生代:每次垃圾收集都有大批对象死去,只有少量存活,那就采用复制算法。
- 老年代:对象存活效率高,没有额外的空间进行分配担保,就是用“标记-清理”或者“标记-整理”算法。
注:https://www.jianshu.com/p/110fb44c60ed
2.下面我们来说说JVM中的线程私有空间:
1).虚拟机栈:
当Java虚拟机运行程序时。每当一个新的线程被创建时。Java 虚拟机都会分配一个虚拟机栈,Java虚拟机栈是以栈帧为单位来保存线程的运行状态。Java虚拟机栈只会有两种操作:以栈帧为单位进行压栈跟出栈。
Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有
的,它的生命周期与线程相同
。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)
用于存储局部变量表、操作栈、动态链接、方法出口
等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表:
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和return Address类型(指向了一条字节码指令的地址)。
其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在Java虚拟机规范中,对这个区域规定了两种异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常; - 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出
OutOfMemoryError
异常。
局部变量表是一组变量值存储空间
,用于存放方法参数
和方法内部定义的局部变量
。在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。
操作数栈
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中。
操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。
当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。
动态连接
在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。
这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。
返回地址
方法的返回分为两种情况:
- 一种是正常退出,退出后会根据方法的定义来决定是否要传返回值给上层的调用者。
- 一种是异常导致的方法结束,这种情况是不会传返回值给上层的调用方法。
不过无论是那种方式的方法结束,在退出当前方法时都会跳转到当前方法被调用的位置。
- 如果方法是正常退出的,则
调用者的PC计数器的值就可以作为返回地址
; - 如果是因为异常退出的,则是
需要通过异常处理表来确定
。
方法的的一次调用就对应着栈帧在虚拟机栈中的一次入栈出栈操作,因此方法退出时可能做的事情包括:恢复上层方法的局部变量表以及操作数栈,如果有返回值的话,就把返回值压入到调用者栈帧的操作数栈中,还会把PC计数器的值调整为方法调用入口的下一条指令。
2).本地方法栈:(同样线程私有)
本地方法栈(Native MethodStacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native 方法服务
。本地方法栈也是``线程私有``的。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError
和OutOfMemoryError
异常。
3).程序计数器 - Program Counter Register:(同样线程私有)
就是一个记录程序执行到哪里了的一个东西,我是这么理解的。
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器
。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器
,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”
的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。
此内存区域是唯一
一个在Java虚拟机规范中没有规定任何OutOfMemoryError
情况的区域。
注:https://www.jianshu.com/p/0ecf020614cb
至此我们弄完了,Java虚拟机里面的内存结构的东西!!!
还讲了一些延伸的东西。eg:垃圾回收算法,引用类型。
下面我们来说类加载的流程:
java文件在编译过程中被编译成class文件,java文件我们都知道见过,下面我们来看看class文件的结构:
ClassFile {
u4 magic; // 魔数
u2 minor_version; // 副版本号
u2 major_version; // 主版本号
u2 constant_pool_count; // 常量池计数器
cp_info constant_pool[constant_pool_count-1]; // 常量池
u2 access_flags; // 访问标志
u2 this_class; // 类索引
u2 super_class; // 父类索引
u2 interfaces_count; // 接口计数器
u2 interfaces[interfaces_count]; // 接口表
u2 fields_count; // 字段计数器
field_info fields[fields_count]; // 字段表
u2 methods_count; // 方法计数器
method_info methods[methods_count]; // 方法表
u2 attributes_count; // 属性计数器
attribute_info attributes[attributes_count]; // 属性表
}
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部都是程序运行的必要数据。根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储,这种伪结构中只有两种数据类型:无符号数和表。无符号数属于基本数据类型,以u1、u2、u4、u8来分别代表1、2、4、8个字节的无符号数。表是由多个无符号数或其他表作为数据项构成的符合数据类型,所有的表都习惯性地以“_info”结尾。
整个Class文件本质上就是一张表,它由如下所示的数据项构成。
从表中可以看出,无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的该数据项的形式,称这一系列连续的摸一个类型的数据为某一类型的集合,比如,fields_count个field_info表数据构成了字段表集合。这里需要说明的是:Class文件中的数据项,都是严格按照上表中的顺序和数量被严格限定的,每个字节代表的含义,长度,先后顺序等都不允许改变。
各部分介绍:
magic
魔数,魔数的唯一作用是确定这个文件是否为一个能被虚拟机所接受的 Class 文件。魔数值固定为 0xCAFEBABE,不会改变。
minor_version、major_version
副版本号和主版本号,minor_version 和 major_version 的值分别表示 Class 文件的副、主版本。它们共同构成了 Class 文件的格式版本号。譬如某个 Class 文件的主版本号为 M,副版本号为 m,那么这个 Class 文件的格式版本号就确定为 M.m。Class 文件格式版本号大小的顺序为:1.5 < 2.0 < 2.1。
一个 Java 虚拟机实例只能支持特定范围内的主版本号(Mi 至 Mj)和 0 至特定范围内(0 至 m)的副版本号。假设一个 Class 文件的格式版本号为 V,仅当 Mi.0 ≤ v ≤ Mj.m
成立时,这个 Class 文件才可以被此 Java 虚拟机支持。不同版本的 Java 虚拟机实现支持的版本号也不同,高版本号的 Java 虚拟机实现可以支持低版本号的 Class 文件,反之则不成立 。
注意:Oracle 的 JDK 在 1.0.2 版本时,支持的 Class 格式版本号范围是 45.0 至 45.3;JDK 版本在 1.1.x时,支持的 Class 格式版本号范围扩展至 45.0 至 45.65535;JDK 版本为 1. k 时(k ≥2)时,对应的 Class文件格式版本号的范围是 45.0 至 44+k.0
constant_pool_count
常量池计数器,constant_pool_count的值等于constant_pool表中的成员数加1。constant_pool 表的索引值只有在大于 0 且小于 constant_pool_count 时才会被
认为是有效的 ,对于 long 和 double 类型有例外情况,后续在讲解。
注意:虽然值为 0 的 constant_pool 索引是无效的,但其他用到常量池的数据结构可以使用索引 0 来表示“不引用任何一个常量池项”的意思。
constant_pool[ ]
常量池,constant_pool 是一种表结构,它包含 Class 文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其它常量。常量池中的每一项都具备相同的格式特征——第一个字节作为类型标记用于识别该项是哪种类型的常量,称为“tagbyte”。常量池的索引范围是 1 至 constant_pool_count−1。
它的用处在于:Java虚拟机指令不依赖于运行时的布局,编译时没有链接这一步,所以指令引用的是constant_pool表中的符号信息
通用结构:
cp_info {
u1 tag;
u1 info[];
}
表中的每一项都以一个1byte的标志位开头,指明该项的常量类型,之后是紧跟相应的内容,每种类型都有自己的结构。
常量池中的每一项常量都是一个表,共有11种(JDK1.7之前)结构各不相同的表结构数据,每种表开始的第一位是一个u1类型的标志位(1-12,缺少2),代表当前这个常量属于的常量类型。11种常量类型所代表的具体含义如下表所示:
常量池主要存放两大类常量:字面量(literal)和符号引用。字面量比较接近java语言层面的常量概念,比如文本字符串、声明的final的常量值等。符号引用属于编译原理方面概念。包括下面三类常量:类和接口的全局限定名。字段的名称和描述符。方法的名称和描述符。这三类稍后在详细讲解。
access_flag
在常量池结束之后,紧接着的2个字节代表访问标志(access_flag),这个标志用于识别一些类或接口层次的访问信息,包括:这个Class是类还是接口,是否定义为public类型,abstract类型,如果是类的话,是否声明为final,等等。每种访问信息都由一个十六进制的标志值表示,如果同时具有多种访问信息,则得到的标志值为这几种访问信息的标志值的逻辑或。
this_class
类索引,this_class 的值必须是对 constant_pool 表中项目的一个有效索引值。constant_pool 表在这个索引处的项必须为 CONSTANT_Class_info 类型常量,表示这个 Class 文件所定义的类或接口。
super_class
父类索引,对于类来说,super_class 的值必须为 0 或者是对 constant_pool 表中项目的一个有效索引值。如果它的值不为 0,那 constant_pool 表在这个索引处的项必须为 CONSTANT_Class_info 类型常量,表示这个 Class 文件所定义的类的直接父类。当前类的直接父类,以及它所有间接父类的 access_flag 中都不能带有 ACC_FINAL 标记。对于接口来说,它的 Class 文件的 super_class 项的值必须是对 constant_pool 表中项目的一个有效索引值。constant_pool 表在这个索引处的项必须为代表 java.lang.Object 的 CONSTANT_Class_info 类型常量。如果 Class 文件的 super_class 的值为 0,那这个 Class 文件只可能是定义的是java.lang.Object 类,只有它是唯一没有父类的类。
interfaces_count
接口计数器,interfaces_count 的值表示当前类或接口的直接父接口数量。
interfaces[]
接口表,interfaces[]数组中的每个成员的值必须是一个对 constant_pool 表中项目的一个有效索引值,它的长度为 interfaces_count。每个成员 interfaces[i] 必须为 CONSTANT_Class_info 类型常量,其中 0 ≤ i <interfaces_count。在 interfaces[]数组中,成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即 interfaces[0]对应的是源代码中最左边的接口。
fields_count
字段计数器,fields_count 的值表示当前 Class 文件 fields[]数组的成员个数。fields[]数组中每一项都是一个 field_info 结构的数据项,它用于表示该类或接口声明的类字段或者实例字段(也就是说静态变量和成员变量都在字段表中) 。
注意::类字段即被声明为 static 的字段,也称为类变量或者类属性,同样,实例字段是指未被声明为static 的字段。由于《Java 虚拟机规范》中,“Variable”和“Attribute”出现频率很高且在大多数场景中具备其他含义,所以译文中统一把“Field”翻译为“字段”,即“类字段”、“实例字段”。
fields[]
字段表,fields[]数组中的每个成员都必须是一个 fields_info 结构的数据项,用于表示当前类或接口中某个字段的完整描述。fields[]数组描述当前类或接口声明的所有字段,但不包括从父类或父接口继承的部分。
methods_count
方法计数器,methods_count 的值表示当前 Class 文件 methods[]数组的成员个数。Methods[]数组中每一项都是一个 method_info 结构的数据项。
methods[]
方法表,methods[]数组中的每个成员都必须是一个 method_info 结构的数据项,用于表示当前类或接口中某个方法的完整描述。如果某个 method_info 结构的 access_flags 项既没有设置 ACC_NATIVE 标志也没有设置 ACC_ABSTRACT 标志,那么它所对应的方法体就应当可以被 Java 虚拟机直接从当前类加载,而不需要引用其它类。method_info 结构可以表示类和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法和类或接口初始化方法。methods[]数组只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。
attributes_count
属性计数器,attributes_count 的值表示当前 Class 文件 attributes 表的成员个数。attributes 表中每一项都是一个 attribute_info 结构的数据项。
attributes[]
属性表,attributes 表的每个项的值必须是 attribute_info 结构。
属性表(attribute_info)在前面已经出现过多次,在Class文件、字段表、方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但Java虚拟机运行时会忽略掉它不认识的属性。Java虚拟机规范中预定义了9项虚拟机应当能识别的属性(JDK1.5后又增加了一些新的特性,因此不止下面9项,但下面9项是最基本也是必要,出现频率最高的),如下表所示:
对于每个属性,它的名称都需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,每个属性值的结构是完全可以自定义的,只需说明属性值所占用的位数长度即可。一个符合规则的属性表至少应具有“attribute_name_info”、“attribute_length”和至少一项信息属性。
1).Code属性
前面已经说过,Java程序方法体中的代码经过Javac编译后,生成的字节码指令便会存储在Code属性中,但并非所有的方法表都必须存在这个属性,比如接口或抽象类中的方法就不存在Code属性。如果方法表有Code属性存在,那么它的结构将如下表所示:
attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为“Code”,它代表了该属性的名称。attribute_length指示了属性值的长度,由于属性名称索引与属性长度一共是6个字节,所以属性值的长度固定为整个属性表的长度减去6个字节。
max_stack代表了操作数栈深度的最大值,max_locals代表了局部变量表所需的存储空间,它的单位是Slot,并不是在方法中用到了多少个局部变量,就把这些局部变量所占Slot之和作为max_locals的值,原因是局部变量表中的Slot可以重用。
code_length和code用来存储Java源程序编译后生成的字节码指令。code用于存储字节码指令的一系列字节流,它是u1类型的单字节,因此取值范围为0x00到0xFF,那么一共可以表达256条指令,目前,Java虚拟机规范已经定义了其中200条编码值对应的指令含义。code_length虽然是一个u4类型的长度值,理论上可以达到2^32-1,但是虚拟机规范中限制了一个方法不允许超过65535条字节码指令,如果超过了这个限制,Javac编译器将会拒绝编译。
字节码指令之后是这个方法的显式异常处理表集合(exception_table),它对于Code属性来说并不是必须存在的。它的格式如下表所示:
它包含四个字段,这些字段的含义为:如果字节码从第start_pc行到第end_pc行之间(不含end_pc行)出现了类型为catch_type或其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理,当catch_pc的值为0时,代表任何的异常情况都要转到handler_pc处进行处理。异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现Java异常即finally处理机制,也因此,finally中的内容会在try或catch中的return语句之前执行,并且在try或catch跳转到finally之前,会将其内部需要返回的变量的值复制一份副本到最后一个本地表量表的Slot中,也因此便有了http://blog.youkuaiyun.com/ns_code/article/details/17485221这篇文章中出现的情况。
Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码和元数据两部分,那么在整个Class文件里,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。
2).Exception属性
这里的Exception属性的作用是列举出方法中可能抛出的受查异常,也就是方法描述时在throws关键字后面列举的异常。它的结构很简单,只有attribute_name_index、attribute_length、number_of_exceptions、exception_index_table四项,从字面上便很容易理解,这里不再详述。
3).LineNumberTable属性
它用于描述Java源码行号与字节码行号之间的对应关系。
4).LocalVariableTable属性
它用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的对应关系。
5).SourceFile属性
它用于记录生成这个Class文件的源码文件名称。
6).ConstantValue属性
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。在Java中,对非static类型的变量(也就是实例变量)的赋值是在实例构造器<init>方法中进行的;而对于类变量(static变量),则有两种方式可以选择:在类构造其中赋值,或使用ConstantValue属性赋值。
目前Sun Javac编译器的选择是:如果同时使用final和static修饰一个变量(即静态常量),并且这个变量的数据类型是基本类型或String的话,就生成ConstantValue属性来进行初始化(编译时Javac将会为该常量生成ConstantValue属性,在类加载的准备阶段虚拟机便会根据ConstantValue为常量设置相应的值),如果该变量没有被final修饰,或者并非基本类型及字符串,则选择在<clinit>方法中进行初始化。
虽然有final关键字才更符合”ConstantValue“的含义,但在虚拟机规范中并没有强制要求字段必须用final修饰,只要求了字段必须用static修饰,对final关键字的要求是Javac编译器自己加入的限制。因此,在实际的程序中,只有同时被final和static修饰的字段才有ConstantValue属性。而且ConstantValue的属性值只限于基本类型和String,很明显这是因为它从常量池中也只能够引用到基本类型和String类型的字面量。
下面简要说明下final、static、static final修饰的字段赋值的区别:
- static修饰的字段在类加载过程中的准备阶段被初始化为0或null等默认值,而后在初始化阶段(触发类构造器<clinit>)才会被赋予代码中设定的值,如果没有设定值,那么它的值就为默认值。
- final修饰的字段在运行时被初始化(可以直接赋值,也可以在实例构造器中赋值),一旦赋值便不可更改;
- static final修饰的字段在Javac时生成ConstantValue属性,在类加载的准备阶段根据ConstantValue的值为该字段赋值,它没有默认值,必须显式地赋值,否则Javac时会报错。可以理解为在编译期即把结果放入了常量池中。
7).InnerClasses属性
该属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那么编译器将会为它及它所包含的内部类生成InnerClasses属性。
8).Deprecated属性和Synthetic属性
该属性用于表示某个类、字段和方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用@Deprecated注释进行设置。
9).Synthetic属性
该属性代表此字段或方法并不是Java源代码直接生成的,而是由编译器自行添加的,如this字段和实例构造器、类构造器等。
注:
https://blog.youkuaiyun.com/ns_code/article/details/17675609
https://blog.youkuaiyun.com/sinat_38259539/article/details/78248454
https://www.jianshu.com/p/93318f387d04
介绍完了class文件的结构,下面我们来说说类加载过程。
Java 类加载过程
Class的生命周期
一个Class在虚拟机中的完整生命周期如下图所示:
需要说明的是,上述的流程只是描述了逻辑上各个阶段的开始顺序,实际过程中,各个阶段可能是交错进行,并不是一个阶段等到另一个阶段完全完成才开始执行。
加载
加载一个Class需要完成以下3件事:
- 通过Class的全限定名获取Class的二进制字节流
- 将Class的二进制内容加载到虚拟机的方法区
- 在内存中生成一个java.lang.Class对象表示这个Class
获取Class的二进制字节流这个步骤有多种方式:
- 从zip中读取,如:从jar、war、ear等格式的文件中读取Class文件内容
- 从网络中获取,如:Applet
- 动态生成,如:动态代理、ASM框架等都是基于此方式
- 由其他文件生成,典型的是从jsp文件生成相应的Class
校验
验证一个Class的二进制内容是否合法,主要包括4个阶段:
- 文件格式验证,确保文件格式符合Class文件格式的规范。如:验证魔数、版本号等。
- 元数据验证,确保Class的语义描述符合Java的Class规范。如:该Class是否有父类、是否错误继承了final类、是否一个合法的抽象类等。
- 字节码验证,通过分析数据流和控制流,确保程序语义符合逻辑。如:验证类型转换是合法的。
- 符号引用验证,发生于符号引用转换为直接引用的时候(转换发生在解析阶段)。如:验证引用的类、成员变量、方法的是否可以被访问(IllegalAccessError),当前类是否存在相应的方法、成员等(NoSuchMethodError、NoSuchFieldError)。
准备
在准备阶段,虚拟机会在方法区中为Class分配内存,并设置static成员变量的初始值为默认值。注意这里仅仅会为static变量分配内存(static变量在方法区中),并且初始化static变量的值为其所属类型的默认值。如:int类型初始化为0,引用类型初始化为null。即使声明了这样一个static变量:
public static int a = 123;
在准备阶段后,a在内存中的值仍然是0, 赋值123这个操作会在中初始化阶段执行,因此在初始化阶段产生了对应的Class对象之后a的值才是123 。
解析
解析阶段,虚拟机会将常量池中的符号引用替换为直接引用,解析主要针对的是类、接口、方法、成员变量等符号引用。在转换成直接引用后,会触发校验阶段的符号引用验证,验证转换之后的直接引用是否能找到对应的类、方法、成员变量等。这里也可见类加载的各个阶段在实际过程中,可能是交错执行。
初始化
初始化阶段即开始在内存中构造一个Class对象来表示该类,即执行类构造器<clinit>()
的过程。需要注意下,<clinit>()
不等同于创建类实例的构造方法<init>()
<clinit>()
方法中执行的是对static变量进行赋值的操作,以及static语句块中的操作。- 虚拟机会确保先执行父类的
<clinit>()
方法。 - 如果一个类中没有static的语句块,也没有对static变量的赋值操作,那么虚拟机不会为这个类生成
<clinit>()
方法。 -
虚拟机会保证
<clinit>()
方法的执行过程是线程安全的。
因此,存在如下一种最简单的单例模式的实现:public class Singleton { public static final INSTANCE = new Singleton(); private Singleton() { } }