1、运行时数据区初探
上几个章节简单的总结了一下类加载器子系统,接下来继续学习JVM里面的运行时数据区。首先,我们来大体的看看运行时数据区处于整个JVM里面的位置。(不同的JVM对于内存的划分存在区别,这里选用的是HotSpot虚拟机)
Class文件使用类加载子系统进行加载,经过load,link,initialize三个流程,加载完以后便在内存中的方法区存放了运行实类本身,接下来需要使用执行引擎将字节码指令解释/编译为对应平台上的本地机器指令,在运行时方法区都分成哪些部分呢?大家各司其职做什么事呢?这便是我们现在需要学习的内容。
2、详细的运行时数据区的划分
1.其中红色部分一个进程只有一份(方法区,堆),多个线程共同享有;灰色部分为一个线程一份,可以有多个线程(程序计数器,本地方法栈,虚拟机栈)。
2.一个JVM对应只有一个Runtime实例,即为运行时环境,相当于上图中的运行时数据区域
3、方法区:
3.1堆、栈、方法区的交互关系
3.2方法区的基本理解
1、 JVM规范中表明在逻辑上方法区是属于堆的一部分(堆里面的元空间可以看作是方法区的具体落地实现),但对于HotSpot虚拟机而言,方法区还有一个别名叫“非堆”,目的是和堆分开,因此,方法区看作是一个独立于堆的内存空间。
通过以下这张图我们可以更为直观的印证以上的猜想
我们编写了一个测试代码,并且将堆的大小设置为600m(-Xms600m -Xmx600m),可以看到我们的元空间大小并没有受到影响,这更加说明方法区是作为一个独立于堆得存在。
2、方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
3、方法区在JVM启动时就会被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的
4、方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展
5、方法区的主要作用就是存储类信息,如果系统定义了太多的类,会导致方法区溢出,抛出java.lang.OutOfMemoryError:PermGen space(JDK1.7及以前) 或者 java.lang,OutOfMemoryError:Metaspace(JDK1.8及以后),这也更加说明其实在逻辑上方法区也是堆得一部分,永久代/元空间是方法区的具体落地实现
6.元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不再虚拟机设置的内存中,而是使用本地内存(PC内存)。
通过visualVM可视化分析工具我们不难发现,虽然短短几行代码,但实际上其背后其实加载了非常非常多的类,有2000多个。
3.3设置方法区大小的参数
本文针对JDK1.8及以后的参数设置
1.元空间大小:可以使用参数-XX:MetaspaceSize=和-XX:MaxMetaspaceSize=设置初始值和最大值
2.元空间默认值:依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
3.补充细节:我们一般设置参数的时候只设置初始值,不设置最大值,因为-XX:MetaspaceSize默认值是21m,也叫初始高水位线。一旦触及这个水位线,Full GC将会被触发并卸载没用的类,同时高水位线会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。我们都知道Full GC是整堆回收,他会触发STW,会暂停用户线程比较久时间,如果频繁的触及高水位线频繁的GC,对用户体验非常不好,因此我们一般会稍微将初始值调高。
3.4方法区的内部结构
方法区的内部结构:
方法区最经典的所要存储的信息:
类型信息:包括类的全限定类名,类的加载类型(class?interface?),类的作用域(private?public?),类的父类信息等。
域信息:域名称、 域类型、域修饰符(public, private, protected, static, final, volatile, transient的某个子集)等
方法信息:方法名称,方法返回类型,方法修饰符 ,方法参数的类型,方法字节码等。
非final修饰的static静态变量
final修饰的static全局常量–编译期就已经赋值
3.5常量池
1.什么是常量池?
常量池属于字节码文件的一部分,我们对任何.java代码通过javap -v -p(私有属性可以查看)进行反编译之后,或者通过JClasslib插件,都会发现一个叫Constant Pool的东西,这便是常量池。常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型、字面量、符号引用等信息。
3.6运行时常量池
1、字节码通过类加载子系统被加载到内存中后(方法区),就是运行时常量池。运行时常量池相对于字节码文件的常量池,具有更重要的特性:动态性。
2、运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量(静态链接),也包括到运行期解析后才能够获得的方法或者字段引用(动态链接)。此时不再是常量池中的符号地址了,这里换为真实地址。
3.7图例演示方法区的使用
示例代码:
1.public class MethodDemo {
2. public static void main(String[] args) {
3. int x = 500;
4. int y = 100;
5. int a = x / y;
6. int b = 50;
7. System.out.println(a + b);
}
}
1.
2.如果不是静态方法,0号位置放的是this方法
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.#2// Field java/lang/System.out:Ljava/io/PrintStream;#2在这里的作用是输出。对应的是java第7行代码
15.
16.
3.8HotSpot虚拟机方法区的演进过程
1、首先关于方法区的具体落地实现永久代,这个概念只在HotSpot虚拟机中才有,其他虚拟机是没有的。HotSpot虚拟机方法区主要经过了三个比较大的版本的变化。
JDK1.6及以前版本:有永久代。方法区的具体落地实现称为永久代,此时的静态变量和字符串常量池是存放在永久代上(方法区)的。
JDK1.7:有永久代。但是关于永久代已经在逐步消失,此时静态变量和字符串常量池不再存储在方法区,而是存储在了堆上。
JDK1.8及以后版本:无永久代。取而代之的是元空间(也是方法区的具体落地实现),只不过这时候的元空间并不再使用JVM的虚拟内存,而是使用的本地内存。此时静态变量和字符串常量池还是保存在堆空间中。
简单的总结HotSpot虚拟机比较大的变化就是使用虚拟内存还是本地内存?静态变量和字符串常量的储存位置是在哪里?
3.9关于方法区的几个问题
为什么永久代会被元空间替代?
1、永久代设置空间大小很难,某些场景如果加载的类过多,容易出现OOM异常。
2、对永久代垃圾回收比较困难。
为什么字符串常量要放到堆里面去?
1、因为如果放在永久代的话,垃圾回收效率很低,需要Full GC才能触发垃圾回收。而我们知道永久代是很少会进行垃圾回收的,垃圾回收大部分都是发生在新生代。如果永久代需要垃圾回收的话,需要老年代和永久代内存空间不足才会触发,而我们又会大量的创建字符串,回收效率很低导致永久代内存不足,因此放到堆里面能及时回收。
4、堆:
4.1堆得概述
1)什么是堆?
答:
堆是用来保存对象实例和数组的。几乎所有对象实例和数组都储存在堆上。
一个进程只会有一个对应的堆,它的大小在随着JVM创建的时候创建,是固定的,当然,你也可以通过**-Xms,-Xmx初始化它的最大最小的内存。
同时,它也是JVM管理里面最大的一块内存空间。
堆在物理上不是连续的,但是在逻辑上应该被视为是连续的。
而且,虽然说堆是线程共有的,但是堆里面也是可以划分私有的缓冲区的(TLAB)。
2)垃圾回收与堆?
答:
堆是垃圾回收的重要区域,在通过方法创建的保存在堆的实例,在方法结束后不会马上被移除,仅仅在垃圾回收的时候才会被移除。
4.2堆得细分内存结构
通过一张思维导图来描述,首先堆的结构主要分为三部分,新生代,老年代,养老代(JDK1.8以前)/元空间(JDK1.8及以后),新生代又分为了Edsn伊甸园区,S0,S1幸存者区,这三者默认的空间比例是8:1:1,可以用过-XX:SurvivorRatio来改变默认的比例。其他不做过多解释,如下图:
补充说明:
1)几乎所有的Java对象都是在Eden区被new出来的,除非创建的对象特别庞大,庞大到Eden区都放不下。
2)绝大部分的Java对象都销毁在新生代了(IBM公司的专门研究表明,新生代80%的对象都是“朝生夕死”的)
4.3图解对象分配的一般过程
第一步、
堆随着我们的JVM创建而创建,死亡而死亡。一开始堆里面是没有任何东西的,是空的,接着随着对象的实例化,new出的对象先会放在伊甸园区,这个区域的大小是有限制的。
第二步、
当伊甸园区满了,我们又要实例化新的对象,JVM垃圾回收器将会对伊甸园区进行垃圾回收(YGC/Mirror GC),不需要的或者失去了引用的对象,将会直接被回收,而需要新加载的实例化的对象,会被继续放入到Eden区。
第三步、
于此同时,如果在Eden区的对象任然有用,那么它会被放到幸存者0区,同时其身上会被标记,可以称之为年龄age。(如下图所示,红色的即表示没有用的对象,被Mirror GC回收了,Eden区有用的对象幸存,于是被放到S0区,同时,age+1)
第四步、
此时,如果Eden区存放的对象没满,则继续重复上一步操作,放到S0区。如果Eden区再次满了,再次触发YGC/Mirror GC对没有用的对象进行垃圾回收,此时如果Eden区有幸存者对象,S0区的对像也还有用,则把Eden区的对象和S0区的对象复制交换到S1区,同时S1区叫from区,S0区叫to区(from,to区并没有准确的定义究竟是S0是from区还是S1是from区,总之,复制交换之后,哪个区为空,哪个区就是to区,表示的是下一次需要交换到的区域)
第五步、
前面的步骤同上,与之不同的是,当S1区有的对象的年龄age达到默认值15(-XX:MaxTenuringThreshold可以修改默认值),则不再复制交换到to区,而是直接放置到老年区。
补充!:
**只有Eden区满了才会触Mirror GC/YGC,而幸存者区满了是绝对不会触发minorGC的。**垃圾回收频繁发生在新生区,很少在老年区回收,几乎不在元空间/老年区回收。
4.4对象分配的特殊情况
首先我们新建了对象,我们首先存放到Eden区里面,Eden区判断放不放的下这个对象,如果放不下,触发YGC垃圾回收对没有用的对象进行垃圾回收,之后重新计算Eden区判断看看放不放的下,如果依然放不下则看看 能不能放得下老年代,如果放得下则直接放入老年代。如果放不下的话这里分为两种情况:
第一种:老年代里面存放了一部分对象,我们通过FGC垃圾回收(又称为Main GC)进行垃圾回收处理没有用的对象,如果处理完之后老年代放得下,则放入老年代,如果老年代还是放不下,就报OOM异常。
第二种:假设老年代空间就是11M,你垃圾回收完了,你的对象占用的内存还是12M,即你回收了内存还是不够用,则直接报OOM异常。
与此同时,我们在进行YGC的同时,Survior区也会被动的触发YGC垃圾回收。同时,Eden区的活下来的对象会被放入幸存者区,我们判断幸存者区放不放的下,如果放不下,直接存放到老年代。
示例代码:
public class HeapInstanceTest {
byte[] buffer = new byte[new Random().nextInt(1024 * 200)];
public static void main(String[] args) {
ArrayList<HeapInstanceTest> list = new ArrayList<HeapInstanceTest>();
while (true) {
list.add(new HeapInstanceTest());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
4.5mirror GC,Major GC,Full GC初入门
JVM在进行GC的时候,并不是每次都针对新生代,老年代,方法区这三个区域一起回收的,大部分的回收都是发生在新生代。
4.6堆空间的分代思想
通过把堆空间分成新生代,老年代,元空间(JDK1,8及之后),可以提高GC的效率,简单来说就是可以优化性能。其实不分堆也行,所有对象存储在一块区域里面,但是由于大部分对象他都是朝生夕死的,每次GC的话就会对这一块区域里面的所有对象进行扫描,这样STW的时间就会比较长,非常影响用户体验,因此划分堆空间,可以针对那些朝生夕死的对象进行很好的回收,进而提高效率,从而达到优化。
4.7对象分配过程:TLAB
1)TLAB的出现背景:正如上文所提到的,堆是线程共享区域,任何线程都可以访问到堆区中的共享数据。由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。多个线程操作一个资源有可能带来并发的问题,因此为了解决这个问题,TLAB应运而生。
2)TLAB所处的位置:Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
默认情况下,TLAB空间的内存非常小,仅占有整个EDen空间的1%,当然我们可以通过选项 ”-XX:TLABWasteTargetPercent“ 设置TLAB空间所占用Eden空间的百分比大小。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配了内存。
在程序中,开发人员可以通过选项“-XX:UseTLAB“ 设置是够开启TLAB空间
4.8逃逸分析观看堆空间分配策略
4.9堆空间参数设置小结
-XX:PrintFlagsInitial: 查看所有参数的默认初始值
-XX:PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
具体查看某个参数的指令: jps
查看当前运行中的进程 jinfo -flag SurvivorRatio 进程id:查看新生代中Eden和S0/S1空间的比例
-Xms: 初始堆空间内存(默认为物理内存的1/64)
-Xmx: 最大堆空间内存(默认为物理内存的1/4)
-Xmn: 设置新生代大小(初始值及最大值)
-XX:NewRatio: 配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄(默认15)
-XX:+PrintGCDetails:输出详细的GC处理日志 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc
-XX:HandlePromotionFailure:是否设置空间分配担保
5、本地方法栈:
1.在讲本地方法栈之前,我们先了解一下本地方法接口。
本地方法接口:一个Native Method就是一个Java调用非Java代码的接口。在java中他没有方法体,但不代表他没有实现的方法。因为他具体的实现是由非java语言在外面实现的,比如C语言。标识符native可以与其他所有的java标识符连用,但是abstract除外。
作用:融合编程语言为java所用。
2.本地方法栈:
1)java栈用于管理java方法的调用,本地方法栈用于管理本地方法的调用。
2)线程私有的,内存大小可以是固定的也可以是动态扩容的,同java栈。
3)是使用C语言实现的。
4)在本地方法栈中登记本地方法,在执行引擎执行时加载本地方法库。
5)当某个线程调用某个本地方法的时候, 它的管控权则不再归JVM所有,它和虚拟机拥有着同样的权限。它可以动过本地方法接口访问JVM运行时数据区,可以直接调用本地的处理器中的寄存器,可以直接从本地内存中的堆中分配任意数量的内存。
6、虚拟机栈(JAVA栈):
1.出现背景:由于跨平台性的设计,java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。根据栈设计的优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
2.内存中的栈与堆:栈是运行时的单位,而堆是存储的单位。即:栈是解决程序运行的问题,即程序如何运行,或者如何处理数据。堆是解决数据储存的问题,即数据怎么存放,放在哪儿。
3.概念:虚拟机栈,也叫java栈,描述的是 Java 方法执行的内存模型。每个线程创建时都会创建一个属于它自己的虚拟机栈,因此它是线程私有的,其内部保存着一个个的栈帧,每个栈帧也对应着一次次的java方法的调用。在一条活动线程里面,一个时间点上,只会有一个活动的栈帧,这个当前执行的栈帧叫当前栈帧,与之对应的方法是当前方法,定义这个方法的类是当前类。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
4.作用:主管java程序的运行,它保存方法的局部变量、8种基本数据类型、对象的引用地址、部分结果,并参与方法的调用和返回。
局部变量:相较于成员变量(成员变量或称属性)
基本数据变量:8种基本数据类型
引用类型变量:类,数组,接口
5.可能出现的异常:对于栈来说不存在垃圾回收问题,但是可能会存在OOM异常以及StackOverflowError两个常见的异常;
StackOverflowError异常:
java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。如果采用固定大小的Java虚拟机栈,那每一个线程的java虚拟机栈容量可以在线程创建的时候独立选定(通过-Xss来设置内存大小,如-Xss256k)。如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会出一个StackOverFlowError异常。
OOM异常:
如果java虚拟机栈可以动态拓展,并且在尝试拓展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个OutOfMemoryError异常
StackOverflowError异常演示
/**
* 演示栈中的异常
*
* 可以通过点击run-edit configurations-vm options中设置栈的大小: -Xss256k
*/
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);//递归来演示StackOverflowError异常
}
}
6.1Java栈之–局部变量表:
1.定义:局部变量表又称为局部变量数组或者本地变量表。定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型
2.大小:局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。对一个函数而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间。
3.有效范围:局部变量表中的变量只在当前方法的调用中有效,即只在当前帧中有效。生命周期随着栈帧的入栈而存活,栈帧的出栈而死亡。
代码演示:
通过javap -v或者通过jclasslib插件进行反编译进行观看,我们观察main方法:
其中locals就是它的最大的大小。
观察它的局部变量表:其中存放了对象类型的引用以及参数等,这和我们前面所说的符合。
解读一下这张图。首先我们从第一张图可以看出main方法的字节码长度是23。观察这张局部变量表的StartPC,例如我拿变量名为j来说,对应的就是StartPC=15,我们根据这个15,到这里的上一张与LineNumberTable对比寻找java行号对应的就是第八行,结合着后面的length=8来说,意思就是J的这个变量的作用域,从字节码第15行开始,作用域剩下的长度为8个单位的的字节码里面。而正好对应的java代码的第八行就是输出语句,这也说明了变量名为j作用于java代码的第八行。
4.关于slot的理解:
slot是变量槽,是局部变量表最基本的储存单元,其实关于slot,个人认为也可以把它理解为索引,因为变量槽里面储存的就是对应的索引。在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
byte、short、char、float在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true,它们均只占据一个slot;long和double则占据两个slot。用一张图表示如下:
通过图不难看出,int类型到long类型的索引只占用了一个slot,而long到float则占用了两个slot。
如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。例如上图中假如我想访问long这个类型的变量,他的索引是1,2,我们只需要访问1即可。
值得注意的是,如果当前帧是由构造方法或者实例方法创建的那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序排列。
例如:通过代码不难看出,这个test1的方法我们能很清楚的看见this引用放在了index为0的slot处,作用于这个方法体的所有内部。
而这个static静态方法很明显就没有this,这也进一步的说明了为什么静态方法内部不能直接调用类方法的成员变量,需要创建类的实例去调用类的成员变量,因为静态方法所生成的java栈里面的局部变量表里面,是没有this的引用的。
5.关于slot的重复利用:
我们直接通过一段代码来看。
按照我们之前的想法,我们会认为局部变量表的长度是4,因为实例调用,this变量一个,a一个,b一个,c一个,长度按理来说应该是4,但实际上呢?
我们观察local variables观测到的长度却是3,这又是为什么呢?
这就是slot的重复利用,我们通过上面的图可以看出,b变量的startPC为4,length为4,对应的java代码行号是37,作用于字节码的域的长度是4,从4开始算,即作用于字节码的4到7行,我们再来看看startPC等于8的位置,对应的就是java代码行40行,这也侧面的说明了b的作用域范围在java代码行里面,就是中括号。从上图可以看出变量C所占用的槽位index也为2,和变量B的槽位相同。这就引出了我们下面的结论:
栈帧中的局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
6.补充说明:
a)在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
b)局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
6.2Java栈之–操作数栈:
1.操作数栈:又叫表达式栈,可以使用链表或数组来实现。每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出的操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)或出栈(pop)。
代码演示:
代码解释:
1.首先调用这个方法,新的栈帧创建之初,栈帧里面的操作数栈是空的,局部变量表也是空的,PC寄存器记录了下一步要操作的指针的指令地址是0,然后开始执行指令地址为0对应的操作指令,即bipush—把15压入到操作数栈中。
2.紧接着PC寄存器同时把指针指向下一个指令地址,执行引擎开始执行,通过操作指令istore_1我们可以看到,15从操作数栈出栈,存放到了局部变量表索引为1的位置。而局部变量表为0的位置,因为该方法是非静态方法,所以0的位置存放了this。
接下来执行的操作同上面的流程一样,这里不做过多解释。
然后依次iload把局部变量表对应索引位置的数据取出来放到操作数栈。
然后执行引擎将操作数栈里的15和8进行运算,求和后重新放到操作数栈临时存储,随后又通过istroe存放到局部变量表里面了。
2.操作数栈的特点:
2.1:操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
2.2:操作数栈就是jvm执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
2.3:每一个操作数栈的最大深度在编译的时候就已经确定好了,保存在方法的code属性中,为max_stack的值。
2.4:操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈push和出栈pop操作来完成一次数据访问
2.5:如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。如:
6.3Java栈之-动态链接(运行时常量池的方法引用)
1.每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。比如invokedynamic指令
2.在Java源文件被编译成字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Refenrence)保存在class字节码文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用(#)最终转换为调用方法的直接引用。
本质上就是一层层的套娃的过程…,下面展示一下代码演示:
代码很简单,就是两个方法,其中方法B调用了A方法。
3.方法的调用:
静态链接 | 动态链接 |
---|---|
当一个字节码文件被装载进JVM内部时,被调用的目标方法在编译期可知,且运行期保持不变。将调用方法的符号引用转换为直接引用的过程称为静态链接 | 如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。 |
早期绑定 | 晚期绑定 |
代码展示早期绑定和晚期绑定:
/**
*
* 说明早期绑定晚期绑定的例子
* */
public class animalTest {
public void showAnimal(Animal animal){
animal.eat();//表现为晚期绑定,只有传入对象才能明确知道是谁eat了
}
public void showHunt(hunTable hunTable){
hunTable.hunt();//接口不能实例化,如果得表现成功,必须提供实现类对象,所以也为晚期绑定
}
//定义一个父类
class Animal{
public void eat(){
System.out.println("动物进食");
}
}
//定义一个接口
interface hunTable{
void hunt();
}
class Dog extends Animal implements hunTable{
@Override
public void hunt() {
System.out.println("捕食耗子多管闲事");
}
@Override
public void eat() {
System.out.println("狗吃骨头");
}
}
class Cat extends Animal implements hunTable{
public Cat(){
super();//早期绑定,通过super调用父类空参构造器,明确知道是父类的
}
public Cat(String name){
this();//调用当前类的构造器,就是上面这个空参构造器,早期绑定,明确知道是本类的
}
@Override
public void hunt() {
super.eat();//早期绑定
System.out.println("猫吃老鼠天经地义");
}
@Override
public void eat() {
System.out.println("猫吃鱼");
}
}
}
虚函数:
Java中任何一个普通的方法其实都具备虚函数(可以理解为多态)的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法(因为被final标记的方法不能被重写,自然而然就不能多态)。
虚方法和非虚方法:
非虚方法:静态方法,实例构造器,final方法,私有方法,父类方法都是非虚方法,即在编译期间就明确的知道具体是谁调用,同虚函数的意思。
虚方法:其他所有体现多态性的方法称为虚方法
补充:
子类对象的多态性使用前提:
①类的继承关系(父类的声明)②方法的重写(子类的实现)
四条常见的方法调用指令:
1.invokestatic–非虚方法–调用静态方法,编译时确定
2.invokespecial–非虚方法–私有方法,父类方法等
3.invokevirtual–虚方法,除了被final修饰的方法以外
4.invokeinterface–虚方法
演示代码:
class Father {
public Father(){
System.out.println("父类默认构造器");
}
public static void showStatic(String s){
System.out.println("父类 showStatic"+s);
}
public final void showFinal(){
System.out.println("Father showFinal");
}
public void showCommon(){
System.out.println("Father showCommon");
}
}
public class Son extends Father{
public Son(){
super();
}
//不是重写的父类方法,因为静态方法不能被重写
public static void showStatic(String s){
System.out.println("子类 showStatic"+s);
}
private void showPrivate(String s){
System.out.println("Son showPrivate"+s);
}
public void show(){
showStatic(" 儿子");
super.showStatic(" 儿子");
showPrivate(" 你好!");
super.showCommon();
//invokeVirtual 因为此方法声明有final 不能被子类重写,所以也认为该方法是非虚方法
showFinal();
//invokeVirtual
showCommon();//没有显式加super,被认为是虚方法,因为子类可能重写showCommon
info();
}
public void info(){
}
public static void main(String[] args) {
Son son = new Son();
son.show();
}
}
结果展示:
方法重写的本质:
方法重写其实就是当子类重写父类的方法时,当调用被重写方法的时候,会先从子类去寻找,如果子类没有再到父类去寻找,但这样会造成效率底下的问题,为了解决这一个问题,提高性能,jvm在类的方法区建立了一个虚方法表来实现,使用索引表来代替查询(非虚方法没有虚方法表)。
虚方法表:
1.每个类都有一个虚方法表,存放着各个方法的实际入口
2.虚方法表在linking链接阶段初始化
举例说明:
现在有两个类,一个类是son类,一个类是father类,其中son类继承自father类,这两个类都间接继承自objec类,其中father类写了两个方法,hardChoice(QQ),hardChoice(360),Son类重写了父类的两个方法。这两个类建立的各自的虚方法表中,假如Son类调用了toString方法,本身没重写过,父类也没重写过,那么他直接调用的是Object类的toString方法,用不着像上面说的一层一层往上父类寻找。对于重写过的方法, 虚方法表中存储的就是自己这个类的方法入口,直接调用即可,不需要网上寻找。
6.4Java栈之-方法返回地址:
作用:存放调用该方法的PC寄存器的值,当当前方法结束之后,将该值返回给上一个方法。因为我们知道方法的调用是通过栈来操作的,在java栈中,一个个栈帧就代表着一个个的方法,当方法A调用的时候,A方法入栈成为当前方法,假设在方法A中调用了方法B,方法B入栈,成为新的当前方法,现在我们方法B执行完成了,我们得恢复到方法A未完成的部分,可以理解为回到现场,那么此时我们就要通过方法返回地址,将B的PC寄存器的值通过返回地址返回到A方法中(PC寄存器存放的是指向下一条指令的地址)这。这样我们的A方法就能够接着未完成的状态执行下去。
首先我们得知道一个方法结束的方式有两种:
第一种:正常执行完成
第二种:出现异常未处理,非正常退出
无论哪种退出方式,当方法调用结束退出后,都会回到该方法被调用的位置。方法正常退出,调用者的PC寄存器的值作为返回地址,即调用该方法的指令的下一条指令地址。而通过异常方法退出的,栈帧不会储存这一部分信息,返回地址是通过异常表确定的。
6.5Java栈之-常见的面试问题
1.举例栈溢出的情况?
答:可以通过-Xss来设置内存大小,针对栈内存是固定的情况
2.调整栈的大小能保证不出现溢出吗?
答:理论上不能,调整栈大小只能保证溢出的时间晚一些。
3.垃圾回收是否会涉及到虚拟机栈?
答:java虚拟机栈只会涉及到内存溢出的问题,不会涉及到GC垃圾回收机制。GC垃圾回收机制主要作用域堆和方法区
4.栈分配的内存越大越好吗?
答:不会,java栈是线程私有的,一个线程占用的内存空间是有限的,如果你分配的栈越大,而内存空间不变,就会挤压到别的线程的空间。
5.方法中定义的局部变量是否线程安全?
答:单个线程操作其实是安全的,但如果是多个线程操作同一个资源,如果用的是没有经过同步的方法,那么是会存在线程安全的问题的。更多详细的可以看我的另一篇文章多线程进阶复习JUC并发编程
7、程序计数器(PC寄存器):
1.概念:存储指令现场相关信息,CPU只有把数据装载到寄存器才能够运行。简单理解为行号指示器。
2.作用:用来储存指向下一条指令的地址,即将要执行的指令代码,由执行引擎读取下一条指令。
3.特点:
3.1:占用很小的内存空间几乎可以忽略不计;
3.2:每个线程单独私有;
举例:因为多线程并发执行的本质其实就是在极短的时间内线程轮流切换实现的,因此线程在当前时间段没有执行完的时候,CPU分配的使用时间就已经结束了,等待下一次的CPU调用,我们为了确保下一次抢占到CPU资源的时候恢复到现在执行的这个状态,因此我们需要一个寄存器去记录当前执行的情况,也就是现场信息,因此它是私有的。
3.3:任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果实在执行native(本地方法栈)方法,则是未指定值(undefined),因为程序计数器不负责本地方法栈。
3.4:分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完成。
3.5:字节码解释器工作时就是通过改变这个计数器的值来选取下一跳需要执行的字节码指令。
3.6:唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError(OOM)异常(内存溢出异常)情况的区域。既没有GC也没有OOM
一个简单的举例,代码如下:
public class testPC {
public static void main(String[] args) {
int i = 1;
int j = 2;
int k = 3;
String s = "abc";
System.out.println(i);
System.out.println(k);
}
}
在控制台通过javap -v 文件.class进行反编译
我们看看编译后的结果:
Classfile /E:/2019IDEA/workspace1/JVM02/target/classes/testPC.class
Last modified 2020年10月2日; size 613 bytes
MD5 checksum 0eb474b7843299f6dbc7ff5882cad237
Compiled from "testPC.java"
public class testPC
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #5 // testPC
super_class: #6 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool://常量池
#1 = Methodref #6.#26 // java/lang/Object."<init>":()V
#2 = String #27 // abc
#3 = Fieldref #28.#29 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#5 = Class #32 // testPC
#6 = Class #33 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LtestPC;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 i
#19 = Utf8 I
#20 = Utf8 j
#21 = Utf8 k
#22 = Utf8 s
#23 = Utf8 Ljava/lang/String;
#24 = Utf8 SourceFile
#25 = Utf8 testPC.java
#26 = NameAndType #7:#8 // "<init>":()V
#27 = Utf8 abc
#28 = Class #34 // java/lang/System
#29 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#30 = Class #37 // java/io/PrintStream
#31 = NameAndType #38:#39 // println:(I)V
#32 = Utf8 testPC
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (I)V
{
public testPC();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LtestPC;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iconst_3
5: istore_3
6: ldc #2 // String abc从常量池取常量,具体存储位置为#2
8: astore 4 //astore 表示储存
10: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_1
14: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
17: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 打印i的值涉及到的相关
20: iload_3
21: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
24: return
LineNumberTable:
line 5: 0
line 6: 2
line 7: 4
line 8: 6
line 9: 10
line 10: 17
line 11: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
2 23 1 i I
4 21 2 j I
6 19 3 k I
10 15 4 s Ljava/lang/String;
}
SourceFile: "testPC.java"
我们拿main方法里面的一部分来说明,具体如下:红色部分表示的是操作指令,蓝色部分表示的是偏移地址,我们的程序计数器存放的就是偏移地址,它指明了下一步要执行的操作指令地址,执行引擎会从pc寄存器中取出对应的指令进行相关的操作,如图所示:
8、运行时数据区小结
面试题补充:
蚂蚁金服:
Java8的内存分代改进?
JVM内存分哪几个区,每个区的作用是什么?
一面: JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?
二面: Eden和Survior的比例分配小米: jvm内存分区,为什么要有新生代和老年代
字节跳动: 二面: Java的内存分区 二面:讲讲jvm运行时数据库区 什么时候对象会进入老年代?
京东: JVM的内存结构,Eden和Survivor比例 。
JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和Survivor。天猫: 一面: Jvm内存模型以及分区,需要详细到每个区放什么。 一面: JVM的内存模型,Java8做了什么修改
拼多多: JVM内存分哪几个区,每个区的作用是什么?
美团: java内存分配 jvm的永久代中会发生垃圾回收吗? 一面: jvm内存分区,为什么要有新生代和老年代?