JVM上篇学习1–内存与垃圾回收(JVM与Java体系结构、类加载子系统、运行时数据区概述及线程、PC寄存器、虚拟机栈)
目录
1. JVM与Java体系结构
1.1 前言
- 是否遇到这些问题?
- 运行着的线上系统突然卡死,系统无法访问,甚至直接OOM!
- 想解决线上JVM GC问题,但无从下手
- 新项目上线,对各种JVM参数设置一脸茫然,直接默认吧,然后就挂了
- 面试经常被问到如何调优JVM参数,如何解决GC,OOM问题
- 开发人员如何看待上层框架
- 如果把核心类库的API比作数学公式,那么Java虚拟机的知识就是好比公式推导。
-
计算机系统体系对我们来说越来越远,在不了解底层实现方式的前提下,通过高级语言很容易编写代码。但事实上计算机并不认识高级语言。
-
为什么要学JVM?
-
中高级程序员必备技能(项目管理、优化的需要)
-
追求极客的精神(垃圾回收算法、JIT、底层原理)
1.2 参考书目
1.3 Java及JVM简介
-
TIOBE 语言热度排行榜:https://www.tiobe.com/tiobe-index/
-
世界上没有最好的编程语言,只有最实用具体场景的编程语言
-
Java 生态圈
- Java 是目前应用最为广泛的软件开发平台之一。随着Java以及社区的不断壮大,Java也不是简简单单的一门计算机语言,它更是一个平台、一种文化、一个社区
- 作为一个平台:Java虚拟机扮演着重要的作用(Groovy,Scala,JRuby,Kotlin等都是Java平台的一部分)
- 作为一种文化,Java几乎成为“开源”的代名词
- 第三方开源软件和框架,如Tomcat,Structs,MyBatis,Spring等。
- 就连JDK和JVM自身也有不少开源的实现,如OpenJDK,Harmony
- 作为社区,java拥有全世界最多的技术拥护者和开源社区支持,有数不清的论坛和资料。从桌面应用软件、嵌入式开发到企业级应用、后台服务器、中间件都可以看到Java的身影。
-
Java:跨平台的语言
-
Java虚拟机规范
-
JVM:跨语言的平台
-
随着Java 7 的正是发布,Java虚拟机的设计者通过JSR-292规范基本实现在Java虚拟机平台上运行费Java语言编写程序。
-
Java虚拟机改变不关系运行在其内部的程序到底是使用何种语言编写的,它只关心“字节码”文件。也就是说Java虚拟机拥有语言无关性,并不会单独地与Java语言“终生绑定”,只要其他编程语言的编译结果满足并包含Java虚拟机的内部指令集、符号表以及其他的辅助信息,它就是一个有效的字节码文件,就能够被虚拟机所识别并装载运行。
-
字节码
-
平时所说的Java字节码,指的是Java语言编译成的字节码,准确的说任何能在JVM平台上执行的字节码都是一样的格式,所以应该统称为JVM字节码
-
不同的编译器,可以编译出相同的字节码问题,字节码问题也可以在不同的JVM上运行
-
Java虚拟机与Java语言并没有必然的联系,它只与特定的二进制文件格式---- Class文件格式所关联,Class 文件包含了Java虚拟机指令集(或者称为字节码,Bytecodes)和符号表,还有一些其他辅助信息。
-
多语言混合编程
-
Java平台上的多语言混个编程正式成为主流,通过特定的领域的语言去解决特定领域的问题是当前软件开发应对日趋复杂项目需求的一个方向
-
试想一下,在一个项目中,并行处理用Clojure语言,展示层用JRuby、Rails,中间层用Java,每个应用层都将使用不同的编程语言来完成,而且,接口对每层的开发者都是透明的,各种语言之间的交互不存在任何困难,就像使用自己语言的原生API一样方便,因为它们最终都是运行在一个虚拟机上的
-
对这些运行在Java虚拟机之上,Java之外的语言,来自系统级的、底层支持正在迅速增长,以JSR-292为核心的一系列项目和功能改进(如Davinci Machine 项目,Nashorn引擎,InvokeDynamic指令,java.lang.invoke包等),推动了java虚拟机从“Java语言的虚拟机”向“多语言虚拟机的方向发展”
1.4 Java发展的重大事件
-
1990年,在Sun计算机公司中,由Patrick Naughton、MikeSheridan及James Gosling领导的小组Green Team,开发出的新的程序语言,命名为Oak,后期命名为Java
-
1995年,Sun正式发布Java和HotJava产品,Java首次公开亮相。
-
1996年1月23日Sun Microsystems发布了JDK 1.0。
-
1998年,JDK1.2版本发布。同时,Sun发布了JSP/Servlet、EJB规范,以及将Java分成了J2EE、J2SE和J2ME。这表明了Java开始向企业、桌面应用和移动设备应用3大领域挺进。
-
2000年,JDK1.3发布,Java HotSpot Virtual Machine正式发布,成为Java的默认虚拟机。
-
2002年,JDK1.4发布,古老的Classic虚拟机退出历史舞台。
-
2003年年底,Java平台的scala正式发布,同年Groovy也加入了Java阵营。
-
2004年,JDK1.5发布。同时JDK1.5改名为JavaSE5.0。
-
2006年,JDK6发布。同年,Java开源并建立了OpenJDK。顺理成章,Hotspot虚拟机也成为了OpenJDK中的默认虚拟机。
-
2007年,Java平台迎来了新伙伴Clojure。
-
2008年,oracle收购了BEA,得到了JRockit虚拟机。
-
2009年,Twitter宣布把后台大部分程序从Ruby迁移到Scala,这是Java平台的又一次大规模应用。
-
2010年,Oracle收购了Sun,获得Java商标和最真价值的HotSpot虚拟机。此时,Oracle拥有市场占用率最高的两款虚拟机HotSpot和JRockit,并计划在未来对它们进行整合:HotRockit。JCP组织管理Java语言
-
2011年,JDK7发布。在JDK1.7u4中,正式启用了新的垃圾回收器G1
-
2017年,JDK9发布。将G1设置为默认GC,替代CMS
-
同年,IBM的J9开源,形成了现在的Open J9社区
-
2018年,Android的Java侵权案判决,Google赔偿Oracle计88亿美元
-
同年,Oracle宣告JavagE成为历史名词JDBC、JMS、Servlet赠予Eclipse基金会
-
同年,JDK11发布,LTS版本的JDK,发布革命性的ZGC,调整JDK授权许可
-
2019年,JDK12发布,加入RedHat领导开发的Shenandoah GC
-
Open JDK和Oracle JDK
- 在JDK11之前,Oracle JDK中还会存在一些Open JDK中没有的,闭源的功能。但在JDK11中,我们可以认为Open JDK和Oracle JDK代码实质上已经达到完全一致的程度了。
- 主要的区别就是两者更新周期不一样
1.5 虚拟机与Java虚拟机
1.5.1 虚拟机
- 所谓虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。
- 大名鼎鼎的Virtual Box,VMware就属于系统虚拟机,它们完全是对物理计算机硬件的仿真(模拟),提供了一个可运行完整操作系统的软件平台。
- 程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虚拟机中执行的指令我们称为Java字节码指令。
- 无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。
1.5.2 Java虚拟机
- Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。
- JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。
- Java技术的核心就是Java虚拟机(JVM,Java Virtual Machine),因为所有的Java程序都运行在Java虚拟机内部。
作用:
Java虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
特点:
- 一次编译,到处运行
- 自动内存管理
- 自动垃圾回收功能
JVM的位置
- JVM是运行在操作系统之上的,它与硬件没有直接的交互
1.6 JVM的整体结构
- Hot Spot VM 是目前市面上高性能虚拟机的代表作之一
- 它采用解释器与即时编译器并存的结构
- 在具今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一较高下的地步
1.7 Java代码执行流程
1.8 JVM的架构模型
Java 编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。
1.8.1 基于栈的指令集架构
- 设计和实现简单,适用于资源受限的系统
- 避开了寄存器的分配难题:使用零地址指令分配方式
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈,指令集更小,编译器容易实现
- 不需要硬件支持,可移植性好,更好的实现跨平台
1.8.2 基于寄存器架构特点
- 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机
- 指令集架构则完全依赖硬件,与硬件的耦合度高,可移植性差
- 性能优秀和执行更高效
- 花费更少的指令去完成一项操作
- 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主
1.8.3 两种架构举例
同样执行2+3这种逻辑操作,其指令分别如下:
- 基于栈的计算流程(以Java虚拟机为例)(8条指令):
iconst_2 //常量2入栈
istore_1
iconst_3 // 常量3入栈
istore_2
iload_1
iload_2
iadd //常量2/3出栈,执行相加
istore_0 // 结果5入栈
- 基于寄存器的计算流程(2条指令)
mov eax,2 //将eax寄存器的值设为1
add eax,3 //使eax寄存器的值加3
1.8.4 基于栈的例子
1.8.5 总结
-
由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。栈的优点:跨平台,指令集小,编译器容易实现,缺点是性能比寄存器差一些。
-
时至今日,尽管嵌入式平台已经不是Java程序的主流运行平台了(准确来说应该是HotSpot VM的宿主环境已经不局限于嵌入式平台了),那么为什么不将架构更换为基于寄存器的架构呢?
-
因为基于栈的架构跨平台性好、指令集小,虽然相对于基于寄存器的架构来说,基于栈的架构编译得到的指令更多,执行性能也不如基于寄存器的架构好,但考虑到其跨平台性与移植性,还是选用栈的架构
-
栈:跨平台性,指令集小,指令多;执行性能比寄存器差
1.9 JVM的生命周期
- 虚拟机的启动
Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
-
虚拟机的执行
- 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序
- 程序开始执行时他才运行,程序结束时他就停止
- 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程
-
虚拟机的退出
-
有如下的几种情况:
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统用现错误而导致Java虚拟机进程终止
- 某线程调用Runtime类或System类的exit()方法,或Runtime类的halt()方法,并且Java安全管理器也允许这次exit()或halt()操作。
- 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况。
1.10 JVM的发展历程
Sun Classic VM
- 早在1996年Java1.0版本的时候,Sun公司发布了一款名为sun classic VM的Java虚拟机,它同时也是世界上第一款商用Java虚拟机,JDK1.4时完全被淘汰。
- 这款虚拟机内部只提供解释器,没有即时编译器,因此效率比较低。【即时编译器会把热点代码的本地机器指令缓存起来,那么以后使用热点代码的时候,效率就比较高】
- 如果使用JIT编译器,就需要进行外挂。但是一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统。解释器就不再工作,解释器和编译器不能配合工作。
- 将字节码指令翻译成机器指令也是需要花时间的,如果只使用JIT,就需要把所有字节码指令都翻译成机器指令,就会导致翻译时间过长,也就是说在程序刚启动的时候,等待时间会很长。
- 而解释器就是走到哪,解释到哪。
- 现在Hotspot内置了此虚拟机。
Exact VM
- 为了解决上一个虚拟机问题,jdk1.2时,Sun提供了此虚拟机。
- Exact Memory Management:准确式内存管理
- 也可以叫Non-Conservative/Accurate Memory Management
- 虚拟机可以知道内存中某个位置的数据具体是什么类型。
- 具备现代高性能虚拟机的维形
- 热点探测(寻找出热点代码进行缓存)
- 编译器与解释器混合工作模式
- 只在Solaris平台短暂使用,其他平台上还是classic vm,英雄气短,终被Hotspot虚拟机替换
HotSpot VM(重点)
HotSpot历史
-
最初由一家名为“Longview Technologies”的小公司设计
-
1997年,此公司被Sun收购;2009年,Sun公司被甲骨文收购。
-
JDK1.3时,HotSpot VM成为默认虚拟机
-
目前Hotspot占有绝对的市场地位,称霸武林。
- 不管是现在仍在广泛使用的JDK6,还是使用比例较多的JDK8中,默认的虚拟机都是HotSpot
- Sun/oracle JDK和openJDK的默认虚拟机
- 默认介绍的虚拟机都是HotSpot,相关机制也主要是指HotSpot的GC机制。(比如其他两个商用虚机都没有方法区的概念)
-
从服务器、桌面到移动端、嵌入式都有应用。
-
名称中的HotSpot指的就是它的热点代码探测技术。
- 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
- 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡
JRockit(商用三大虚拟机之一)
- 专注于服务器端应用:它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行。
- 大量的行业基准测试显示,JRockit JVM是世界上最快的JVM:使用JRockit产品,客户已经体验到了显著的性能提高(一些超过了70%)和硬件成本的减少(达50%)。
- 优势:全面的Java运行时解决方案组合
- JRockit面向延迟敏感型应用的解决方案JRockit Real Time提供以毫秒或微秒级的JVM响应时间,适合财务、军事指挥、电信网络的需要
- Mission Control服务套件,它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具。
- 2008年,JRockit被Oracle收购。
- Oracle表达了整合两大优秀虚拟机的工作,大致在JDK8中完成。整合的方式是在HotSpot的基础上,移植JRockit的优秀特性。
- 高斯林:目前就职于谷歌,研究人工智能和水下机器人
IBM的J9(商用三大虚拟机之一)
- 全称:IBM Technology for Java Virtual Machine,简称IT4J,内部代号:J9
- 市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM广泛用于IBM的各种Java产品。
- 目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的Java虚拟机。
- 2017年左右,IBM发布了开源J9VM,命名为openJ9,交给Eclipse基金会管理,也称为Eclipse OpenJ9
- OpenJDK -> 是JDK开源了,包括了虚拟机
KVM和CDC/CLDC Hotspot
-
Oracle在Java ME产品线上的两款虚拟机为:CDC/CLDC HotSpot Implementation VM
-
KVM(Kilobyte)是CLDC-HI早期产品
-
目前移动领域地位尴尬,智能机被Android和iOS二分天下。
-
KVM简单、轻量、高度可移植,面向更低端的设备上还维持自己的一片市场
- 智能控制器、传感器
- 老人手机、经济欠发达地区的功能手机
-
所有的虚拟机的原则:一次编译,到处运行。
-
Azul VM
-
前面三大“高性能Java虚拟机”使用在通用硬件平台上
-
这里Azul VW和BEA Liquid VM是与特定硬件平台绑定、软硬件配合的专有虚拟机:高性能Java虚拟机中的战斗机
-
Azul VM是Azul Systems公司在HotSpot基础上进行大量改进,运行于Azul Systems公司的专有硬件Vega系统上的Java虚拟机
-
每个Azul VM实例都可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内实现可控的GC时间的垃圾收集器、专有硬件优化的线程调度等优秀特性
-
2010年,Azul Systems公司开始从硬件转向软件,发布了自己的Zing JVM,可以在通用x86平台上提供接近于Vega系统的特性
-
Liquid VM
-
高性能Java虚拟机中的战斗机
-
BEA公司开发的,直接运行在自家Hypervisor系统上
-
Liquid VM即是现在的JRockit VE(Virtual Edition)。Liquid VM不需要操作系统的支持,或者说它自己本身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等。
-
随着JRockit虚拟机终止开发,Liquid vM项目也停止了
-
Apache Marmony
-
Apache也曾经推出过与JDK1.5和JDK1.6兼容的Java运行平台Apache Harmony
-
它是IElf和Intel联合开发的开源JVM,受到同样开源的Open JDK的压制,Sun坚决不让Harmony获得JCP认证,最终于2011年退役,IBM转而参与OpenJDK
-
虽然目前并没有Apache Harmony被大规模商用的案例,但是它的Java类库代码吸纳进了Android SDK
-
Micorsoft JVM
-
微软为了在IE3浏览器中支持Java Applets,开发了Microsoft JVM
-
只能在window平台下运行。但确是当时Windows下性能最好的Java VM
-
1997年,Sun以侵犯商标、不正当竞争罪名指控微软成功,赔了Sun很多钱。微软WindowsXP SP3中抹掉了其VM。现在Windows上安装的jdk都是HotSpot
-
Taobao JVM
-
由AliJVM团队发布。阿里,国内使用Java最强大的公司,覆盖云计算、金融、物流、电商等众多领域,需要解决高并发、高可用、分布式的复合问题。有大量的开源产品
-
基于OpenJDK开发了自己的定制版本AlibabaJDK,简称AJDK。是整个阿里Java体系的基石
-
基于OpenJDK Hotspot VM发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机
-
创新的GCIH(GCinvisible heap)技术实现了off-heap,即将生命周期较长的Java对象从heap中移到heap之外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
-
GCIH中的对象还能够在多个Java虚拟机进程中实现共享
-
使用crc32指令实现JvM intrinsic降低JNI的调用开销
-
PMU hardware的Java profiling tool和诊断协助功能
-
针对大数据场景的ZenGC
-
taobao vm应用在阿里产品上性能高,硬件严重依赖inte1的cpu,损失了兼容性,但提高了性能
-
目前已经在淘宝、天猫上线,把Oracle官方JvM版本全部替换了
-
Dalvik VM
-
谷歌开发的,应用于Android系统,并在Android2.2中提供了JIT,发展迅猛
-
Dalvik VM只能称作虚拟机,而不能称作“Java虚拟机”,它没有遵循 Java虚拟机规范
-
不能直接执行Java的Class文件
-
基于寄存器架构,不是jvm的栈架构
-
执行的是编译以后的dex(Dalvik Executable)文件。执行效率比较高。
-
它执行的dex(Dalvik Executable)文件可以通过class文件转化而来,使用Java语法编写应用程序,可以直接使用大部分的Java API等
-
Android 5.0使用支持提前编译(Ahead of Time Compilation,AoT)的ART VM替换Dalvik VM
-
Graal VM(未来虚拟机)
-
2018年4月,Oracle Labs公开了GraalvM,号称 “Run Programs Faster Anywhere”,勃勃野心。与1995年java的”write once,run anywhere"遥相呼应
-
GraalVM在HotSpot VM基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用。语言包括:Java、Scala、Groovy、Kotlin;C、C++、Javascript、Ruby、Python、R等
-
支持不同语言中混用对方的接口和对象,支持这些语言使用已经编写好的本地库文件
-
工作原理是将这些语言的源代码或源代码编译后的中间格式,通过解释器转换为能被Graal VM接受的中间表示。Graal VM提供Truffle工具集快速构建面向一种新语言的解释器。在运行时还能进行即时编译优化,获得比原生编译器更优秀的执行效率
-
如果说HotSpot有一天真的被取代,Graalvm希望最大。但是Java的软件生态没有丝毫变化
总结
具体JVM的内存结构,其实取决于其实现,不同厂商的JVM,或者同一厂商发布的不同版本,都有可能存在一定差异。主要以Oracle HotSpot VM为默认虚拟机。
2. 类加载子系统
2.1 内存结构概述
-
简图
-
详图
2.2 类加载器与类的加载过程
2.2.1 类加载子系统
- 类加载器子系统负责从文件系统或网络中加载Class文件,Class文件在文件开头有特定的文件标识
- ClassLoader只负责Class文件的加载,至于是否可以运行,则由Exection Engine 决定
- 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
2.2.2 类加载器ClassLoader 角色
- class file 存在于本地磁盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候要加载到JVM当中来根据这个文件实例化出n个一模一样的实例
- class file 加载到JVM中,被称为DNA元数据模板,放在方法区
- 在.class 文件->JVM ->最终成为元数据模板,此过程就要一个运输工具(类加载器 Class Loader),扮演一个快递员的角色
2.2.3 类的加载过程
- 概述
public class HelloLoader {
public static void main(String[] args) {
System.out.println("谢谢ClassLoader加载我....");
System.out.println("你的大恩大德,我下辈子再报!");
}
}
-
它的加载过程是怎么样的呢?
- 执行 main() 方法(静态方法)就需要先加载main方法所在类 HelloLoader
- 加载成功,则进行链接、初始化等操作。完成后调用 HelloLoader 类中的静态方法 main
- 加载失败则抛出异常
-
完整过程
2.2.4 加载
-
通过一个类的全限定名获取定义此类的二进制字节流
-
将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
-
在内存中生成一个代表这个类的java.lang.Class对象, 作为方法区这个类的各种数据访问入口
-
加载.class 文件的方式
- 从本地系统中直接加载
- 通过网络获取,典型场景:web Applet
- 从ZIP压缩包中读取,成为日后jar,war格式的基础
- 运行时计算生成,使用最多的是:动态代理技术
- 由其他文件生成,典型场景:JSP应用
- 从专有数据库提取.class文件,比较少见
- 从加密文件中获取,典型的防Class文件被反编译的保护措施
2.2.5 链接
链接分为三个子阶段:验证 -> 准备 -> 解析
2.2.5.1 验证(verify)
- 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
- 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证
- 举例
- 使用 BinaryViewer软件查看字节码文件,其开头均为 CA FE BA BE ,如果出现不合法的字节码文件,那么将会验证不通过
2.2.5.2 准备(Prepare)
- 为类变量(static变量)分配内存并且设置该类变量的默认初始值,即零值
- 这里不包含用final修饰的static,因为final在编译的时候就会分配好了默认值,准备阶段会显式初始化
- 注意:这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中
举例
- 代码:变量a在准备阶段会赋初始值,但不是1,而是0,在初始化阶段会被赋值为 1
public class HelloAPP {
public static int a =1; // prepare : a=0,initial :a =1
public static void main(String[] args) {
System.out.println(a);
}
}
2.2.5.3 解析(Resolve)
- 将常量池内的符号引用转换为直接引用的过程
- 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
- 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等
- 反编译class文件后可以查看符号引用,下面带# 号的就是符号引用
2.2.6 初始化
- 类的初始化时机
- 创建类的实例
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(比如:Class.forName(“com.Test”))
- 初始化一个类的子类
- Java虚拟机启动时被标明为启动类的类
- JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化,即不会执行初始化阶段(不会调用 clinit() 方法和 init() 方法)
-
clinit()
- 初始化阶段就是执行类构造器方法()的过程
- 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有clinit方法
- clinit()方法中的指令按语句在源文件中出现的顺序执行
- clinit()不同于类的构造器。(关联:构造器是虚拟机视角下的init())
- 若该类具有父类,JVM会保证子类的clinit()执行前,父类的clinit()已经执行完毕
- 虚拟机必须保证一个类的clinit()方法在多线程下被同步加锁
-
举例1 :有static变量
查看下面这个代码的字节码,可以发现有一个clinit()方法
public class ClassInitTest {
private static int num = 1;
static{
num = 2;
number = 20;
System.out.println(num);
//System.out.println(number);//报错:非法的前向引用。
}
/**
* 1、linking之prepare: number = 0 --> initial: 20 --> 10
* 2、这里因为静态代码块出现在声明变量语句前面,所以之前被准备阶段为0的number变量会
* 首先被初始化为20,再接着被初始化成10(这也是面试时常考的问题哦)
*
*/
private static int number = 10;
public static void main(String[] args) {
System.out.println(ClassInitTest.num);//2
System.out.println(ClassInitTest.number);//10
}
}
- clinit字节码
0 iconst_1
1 putstatic #3 <study/JVM/ClassInitTest.num : I>
4 iconst_2
5 putstatic #3 <study/JVM/ClassInitTest.num : I>
8 bipush 20
10 putstatic #5 <study/JVM/ClassInitTest.number : I>
13 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
16 getstatic #3 <study/JVM/ClassInitTest.num : I>
19 invokevirtual #4 <java/io/PrintStream.println : (I)V>
22 bipush 10
24 putstatic #5 <study/JVM/ClassInitTest.number : I>
27 return
-
当代码中有static变量的时候,就会有clinit方法
-
举例2:无static变量
-
说明:若该类具有父类,JVM会保证子类的clinit方法执行之前,父类的clinit方法已经执行完毕
-
加载流程:
- 首先执行main方法需要加载ClassInitTest类
- 获取Son.B 静态变量,需要加载Son类
- Son类的父类是Father类,所以需要先执行Father类的加载,再执行Son类的加载
-
说明:虚拟机必须保证一个类的clinit方法在多线程下被同步加锁
package study.JVM;
public class DeadThreadTest {
public static void main(String[] args) {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + "开始");
DeadThread dead = new DeadThread();
System.out.println(Thread.currentThread().getName() + "结束");
};
Thread t1 = new Thread(r,"线程1");
Thread t2 = new Thread(r,"线程2");
t1.start();
t2.start();
}
}
class DeadThread{
static{
if(true){
System.out.println(Thread.currentThread().getName() + "初始化当前类");
while(true){
// 死循环保证一直处于初始化状态
}
}
}
}
- 测试结果
- 程序卡死,原因分析:
- 两个线程同时去加载DeadThread类,而DeadThread类中静态代码块中有一处死循环
- 先加载DeadThread类的线程抢到了同步锁,然后在静态代码块中执行死循环,而另一个线程在等待同步锁的释放
- 所系无论哪个线程先执行DeadThread类的加载,另一个类也不会继续执行下去(一个类只能被加载一次)
2.3 类加载器分类
2.3.1 概述
- JVM严格支持两种类型的类加载器。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(UserDefined ClassLoader)
- 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生与抽象类ClassLoader的类加载器都划分为自定义类加载器
- 常见的类加载器只有三个,如下图:
public class ClassLoaderTest {
public static void main(String[] args) {
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//获取其上层:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//获取其上层:获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null
}
}
- 尝试获取引导类加载器,获取到的值为 null ,这并不代表引导类加载器不存在,因为引导类加载器右 C/C++ 语言,获取不到
- 两次获取系统类加载器的值都相同:sun.misc.Launcher$AppClassLoader@18b4aac2 ,这说明系统类加载器是全局唯一的
2.3.2 虚拟机自带的加载器
-
启动类加载器(引导类加载器,Bootstrap ClassLoader)
- 这个类加载使用C/C++语言实现的,嵌套在JVM内部
- 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
- 并不继承自java.lang.ClassLoader,没有父加载器
- 加载扩展类和应用程序类加载器,并作为他们的父类加载器
- 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
-
扩展类加载器(Extension ClassLoader)
- Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
- 派生于ClassLoader类
- 父类加载器为启动类加载器
- 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载
-
应用程序类加载器(也称为系统类加载器,AppClassLoader)
- Java语言编写,由sun.misc.LaunchersAppClassLoader实现
- 派生于ClassLoader类
- 父类加载器为扩展类加载器
- 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
- 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
- 通过classLoader.getSystemclassLoader()方法可以获取到该类加载器
2.3.3 用户自定义类加载器
- 什么时候需要自定义类加载器
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,还可以自定义类加载器,来定制类的加载方式。那为什么还需要自定义类加载器?
-
隔离加载类(比如说我假设现在Spring框架,和RocketMQ有包名路径完全一样的类,类名也一样,这个时候类就冲突了。不过一般的主流框架和中间件都会自定义类加载器,实现不同的框架,中间价之间是隔离的)
-
修改类加载的方式
-
扩展加载源(还可以考虑从数据库中加载类,路由器等等不同的地方)
-
防止源码泄漏(对字节码文件进行解密,自己用的时候通过自定义类加载器来对其进行解密)
-
如何自定义类加载器
-
开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
-
在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中
-
在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
-
示例:
package study.JVM;
import java.io.FileNotFoundException;
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] result = getClassFromCustomPath(name);
if (result == null) {
throw new FileNotFoundException();
} else {
//defineClass和findClass搭配使用
return defineClass(name, result, 0, result.length);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}
throw new ClassNotFoundException(name);
}
//自定义流的获取方式
private byte[] getClassFromCustomPath(String name) {
//从自定义路径中加载指定类:细节略
//如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
return null;
}
public static void main(String[] args) {
CustomClassLoader customClassLoader = new CustomClassLoader();
try {
Class<?> clazz = Class.forName("One", true, customClassLoader);
Object obj = clazz.newInstance();
System.out.println(obj.getClass().getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}
2.4 ClassLoader的使用说明
2.4.1关于ClassLoader
-
ClassLoader类,是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)
-
sun.misc.Lanucher 是一个java虚拟机的入口应用
2.4.2 获取ClassLoader的途径
2.5 双亲委派机制
2.5.1 双亲委派机制原理
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派机制,即把请求交由父类处理,它是一种任务委派模式。
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
- 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类,如果将加载任务分配至系统类加载器也无法加载此类,则抛出异常
2.5.2 双亲委派机制代码演示
例1:
- 建立一个java.lang.String 类,写上static代码块
public class String {
static{
System.out.println("我是自定义的String类的静态代码块");
}
}
- 在另外的程序中加载String类,看看加载的String是JDK自带的String类还是自定义的
public class StringTest {
public static void main(String[] args) {
java.lang.String str = new java.lang.String();
System.out.println("hello");
StringTest test = new StringTest();
System.out.println(test.getClass().getClassLoader());
}
}
-
程序并没有输出静态代码块中的内容,看可见仍然加载的是JDK自带的String类
-
将刚刚的类改一下:
package java.lang;
public class String {
static{
System.out.println("我是自定义的String类的静态代码块");
}
//错误: 在类 java.lang.String 中找不到 main 方法
public static void main(String[] args) {
System.out.println("hello,String");
}
}
- 由于双亲委派机制一直找父类,所以最后找到Bootstrap ClassLoader,Bootstrap ClassLoader 找到JDK自带的String类,在那个String类中并没有main()方法,所以报错了
例2:
package java.lang;
public class ShkStart {
public static void main(String[] args) {
System.out.println("hello!");
}
}
java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main"
Process finished with exit code 1
- 即使类名没有重复,也禁止使用java.lang 这种包名,这是一种保护机制
例3:
当加载jdbc.jar用于实现数据流连接的时候
- 现在程序中需要用到SPI接口,而SPI接口属于rt.jar 包中Java核心API
- 然后使用双亲委派机制,引导类加载器把rt.jar 包加载进来,而rt.jar 包中的SPI存在一些接口,接口就需要实现类
- 具体的实现类就涉及了某些第三方的jar包,比如加载SPI的实现类jdbc.jar【首先需要知道jdbc.jar 是基于SPI接口实现的】
- 第三方的jar包中的类属于系统类加载器加载
- 从这里可以看到SPI核心接口由引导类来加载器,SPI具体实现是由系统类加载器加载
2.5.3 双亲委派机制优势
- 避免类的重复加载
- 保护应用程序安全,防止核心API被随意篡改
- 自定义类:自定义java.lang.String 没有被加载
- 自定义类:java.lang.ShkStart(报错:阻止创建java.lang开头的类)
2.5.4 沙箱安全机制
- 自定义String类时:在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java.lang.String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。
- 这样可以保证对java核心源代码的保护,这就是沙箱安全机制。
2.6 其他
2.6.1 如何判断两个class对象是否相同?
- 在JVM中表示两个class对象是否为同一个类存在两个必要条件
- 类的完整类名必须一致,包括包名
- 加载这个类的ClassLoader(指的是ClassLoader实例对象)必须相同
- 换句话说,JVM中, 即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的
2.6.2 对类加载器的引用
- JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的
- 如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中
- 当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的
3.运行时数据区概述及线程
3.1 前言
通过前面的:类的加载 –> 验证 –> 准备 –> 解析 –> 初始化,这几个阶段完成后,就会用到执行引擎对类进行使用,同时执行引擎将会使用到运行时数据区
3.1 概述
3.1.1 运行时数据区结构
3.1.1.1 运行时数据区与内存
- 内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM高效稳定运行。不同的JVM对内存的划分方式和管理机制存在着部分差异。
阿里手册JDK8
3.1.2 线程的内存空间
- Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区:其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
- 灰色的为单独线程私有的,红色的为多个线程共享的。即:
- 线程独有:独立包括程序计数器、栈、本地方法栈
- 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
3.1.3 Runtime 类
- 每一个JVM只有一个Runtime实例,即为运行时环境,相当于内存结构中的中间那个框:运行时环境
3.2 线程
3.2.1 JVM 线程
-
线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行
-
在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射
- 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会被回收
-
操作系统负责将线程安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法
3.2.2 JVM系统线程
如果使用jconsole或者是任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用public static void main(String[])的main线程以及所有这个main线程自己创建的线程。
这些主要的后台系统线程在Hotspot JVM里主要是以下几个:
- 虚拟机线程: 这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是它们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型“stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
- 周期任务线程: 这种线程是时间周期事件的体现(比如中断),它们一般用于周期性操作的调度执行
- GC线程: 这种线程对在JVM里不同种类的垃圾收集行为提供了支持
- 编译线程: 这种线程在运行时会将字节码编译成本地代码
- 信号调度线程: 这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理
4. 程序计数器(PC寄存器)
4.1 PC Register 介绍
-
JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。
-
这里并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
-
是一块很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域。
-
在JVM规范中,每个线程都有自己的程序计数器,是线程私有的,生命周期与线程生命周期保持一致。
-
任何时间已过线程都只有一个方法在执行,就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则未指定值(undefined)。
-
是程序控制流的指示器,分支、循环、跳转、异常处理、线程回复等基础功能都需要依赖这个计数器完成。
-
字节码解释器工作时就是通过这个计数器的值来选取下一条需要执行的字节码指令。
-
是唯一一个在Java虚拟机规范中没有规定任何OutofMemeoryError 情况的区域.
-
作用:
-
PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。
4.2 举例说明
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
String s = "abc";
System.out.println(i);
System.out.println(j);
}
}
- 使用javap -v 指令查看字节码
Classfile /E:/study/out/production/study/study/JVM/PCRegisterTest.class
Last modified 2022年4月10日; size 661 bytes
MD5 checksum f4fabbf893c7c1aee20b87f65e107861
Compiled from "PCRegisterTest.java"
public class study.JVM.PCRegisterTest
minor version: 0
major version: 52
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #5 // study/JVM/PCRegisterTest
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 // study/JVM/PCRegisterTest
#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 Lstudy/JVM/PCRegisterTest;
#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 PCRegisterTest.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 study/JVM/PCRegisterTest
#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 study.JVM.PCRegisterTest();
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 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lstudy/JVM/PCRegisterTest;
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: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: ldc #2 // String abc
12: astore 4
14: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
17: iload_1
18: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
21: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
24: iload_2
25: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
28: return
LineNumberTable:
line 8: 0
line 9: 3
line 10: 6
line 12: 10
line 13: 14
line 14: 21
line 15: 28
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 args [Ljava/lang/String;
3 26 1 i I
6 23 2 j I
10 19 3 k I
14 15 4 s Ljava/lang/String;
}
SourceFile: "PCRegisterTest.java"
4.3 两个常见问题
- 使用PC寄存器存储字节码指令地址有什么用
- 为什么使用PC寄存器记录当前线程的执行地址
- 因为CPU需要不停的切换各个线程,这个时候切换回来以后,就得知道接着从哪里开始继续执行
- JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样子的字节码指令
-
PC 寄存器为什么设定为私有的
- 所谓的多线程在一个特定时间内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立的计算,从而不会出现相互干扰的情况
- 由于CPU时间片轮转限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器的一个内核,只会执行某个线程的一条指令
- 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间相互不影响
-
CPU时间片
- CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片
- 在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行
- 但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行
5. 虚拟机栈
5.1 虚拟机栈概述
-
虚拟机栈出现的背景
- 由于跨平台性的设计,Java的指令都是根据栈来设计的,不同平台CPU架构不同,所以不能设计为基于寄存器的
- 优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令
-
内存中的栈与堆
- 栈是运行时的单位,堆是存储的单位
- 栈解决程序运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储问题,即数据怎么放,放在哪
-
Java 虚拟机栈是什么
- Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈,每个线程在创建时都会创建一个虚拟机栈,其内部保存了一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,是线程私有的
public class StackTest {
public static void main(String[] args) {
StackTest test = new StackTest();
test.methodA();
}
public void methodA() {
int i = 10;
int j = 20;
methodB();
}
public void methodB(){
int k = 30;
int m = 40;
}
}
-
生命周期
- 生命周期和线程一致
-
作用
- 主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回
- 局部变量,它是相较于成员变量来说的(或属性)
- 基本数据类型变量VS引用类型变量(类、数组、接口)
-
栈的特点
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
- JVM直接对栈的操作只有两个
- 每个方法执行,伴随着进栈(入栈,压栈)
- 执行结束后的出栈工作
- 对于栈来说不存在垃圾回收问题
- 栈不需要GC,但是可能存在OOM
-
栈中可能出现的异常?
- Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的
- 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈容许的最大容量,Java虚拟机会抛出一个StackOverflowError异常
- 如果Java虚拟机栈可以动态扩展,并且尝试扩展的时候无法申请到足够的内存,或者在创建新线程时没有足够的的内存去创建对应的虚拟机栈,那么Java虚拟机会抛出一个OutOfMemoryError 异常
- Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的
-
设置栈内存大小
-
可以使用参数 -Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度
举例
public class StackErrorTest {
public static int count =1;
public static void main(String[] args) {
System.out.println(count);
count ++;
main(args);
}
}
- 没有设置参数前,说明栈在11406这个深度溢出了
- 设置栈参数,后得到结果,显示在2454这个深度溢出了,说明设置参数生效
5.2 栈的存储单位
-
栈中存储什么?
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
-
栈的运行原理
- JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”的原则
- 在一条活动线程中,一个时间点行,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前栈帧
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中应用另一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
- Java方法有两种返回函数的方式:一种是正常的函数返回,使用return指令;另一种是抛出异常,不管哪种方式,都会导致栈帧被弹出
-
栈帧的内部结构
-
每个栈帧中存储着:
- 局部变量表(Local Variable)
- 操作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或异常退出的定义)
- 一些附加信息
并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的
5.3 局部变量表
5.3.1 概述
- 局部变量表也被称为局部变量数组或本地变量表
- 定义为一个数字数字,主要用来存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型
- 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
- 局部变量表所需要的容量大小在编译器确定下来的,并保存在方法的Code属性的maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的
- 方法嵌套调用的次数由栈的大小决定。 一般来说,栈越大,方法嵌套调用次数越多。
- 对于一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需要传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用的次数就会减少
- 局部变量表中的变量只有在当前方法调用中有效。
- 在方法执行时,虚拟机通过使用局部变量表完成数值到参数变量列表的传递过程。
- 当方法调用结束后,随着方法栈的调用销毁,局部变量表也会随之销毁
public class LocalVariablesTest {
private int count = 0;
public static void main(String[] args) {
LocalVariablesTest test = new LocalVariablesTest();
int num = 10;
test.test1();
}
//练习:
public static void testStatic(){
LocalVariablesTest test = new LocalVariablesTest();
Date date = new Date();
int count = 10;
System.out.println(count);
//因为this变量不存在于当前方法的局部变量表中!!
// System.out.println(this.count);
}
//关于Slot的使用的理解
public LocalVariablesTest(){
this.count = 1;
}
public void test1() {
Date date = new Date();
String name1 = "baidu.com";
test2(date, name1);
System.out.println(date + name1);
}
public String test2(Date dateP, String name2) {
dateP = null;
name2 = "zhangsan";
double weight = 130.5;//占据两个slot
char gender = '男';
return dateP + name2;
}
public void test3() {
this.count++;
}
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
//变量c使用之前已经销毁的变量b占据的slot的位置
int c = a + 1;
}
}
-
使用Jclasslib来查看字节码,以main方法为例
-
查看具体字节码
-
方法异常信息表
-
杂项(Misc)
-
行号表(Java代码的行号和字节码指令行号的对应关系)
-
局部变量表(生效行数和剩余行数都是针对字节码文件的行数)
-
图中圈出来的东西表示该局部变量的作用域
-
Start PC==11表示在字节码的11行开始生效,也就是Java代码对应的第11行。而声明int num在java代码的是第10行,说明是从声明的下一行开始生效
-
Length== 5表示局部变量剩余有效行数,main方法字节码指令总共有16行,从11行开始生效,那么剩下就是16-11 ==5
-
Ljava/lang/String 前面的L表示引用类型
5.3.2 关于Slot的理解
- 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束
- 局部变量表,最基本的存储单元是Slot(变量槽)
- 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
- 在局部变量表中,32位以内的类型只占用一个Slot(包括returnAddress类型),64位的类型(long和double)占用两个Slot
- byte、short、char在储存前被转换为int,boolean也被转换为int,0表示false,非0表示true
- long和double则占据两个Slot
- JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
- 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量会按照顺序复制到局部变量表中的每一个Slot上
- 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可(如访问long或double)
- 如果当前帧是由构造器或者实例方法创建的,那么该对象引用this将会存放在index为0的Slot处,其余的参数按照顺序继续排列
Slot代码示例
- this存放在index=0的位置
public void test3() {
this.count++;
}
- 局部变量表信息
- 64位的类型(long或double)占用两个Slot
public String test2(Date dateP, String name2) {
dateP = null;
name2 = "zhangsan";
double weight = 130.5;//占据两个slot
char gender = '男';
return dateP + name2;
}
- weight 为double类型,index直接从3跳到5,表明占用了两个Slot
- static 无法调用this(this 不存在与 static 方法的局部变量表中,所以无法调用)
public static void testStatic(){
LocalVariablesTest test = new LocalVariablesTest();
Date date = new Date();
int count = 10;
System.out.println(count);
//因为this变量不存在于当前方法的局部变量表中!!
// System.out.println(this.count);
}
Slot的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明新的局部变量变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
//变量c使用之前已经销毁的变量b占据的Slot的位置
int c = a + 1;
}
- 局部变量 c 重用了局部变量 b 的 Slot 位置
静态变量与局部变量的对比
-
变量分类
- 按照数据类型分:① 基本数据类型 ② 引用数据类型
- 按照在类中声明的位置分:
- 成员变量:在使用前,都经历过默认初始化赋值
- 类变量: linking的prepare阶段:给类变量默认赋值 —> initial阶段:给类变量显式赋值即静态代码块赋值
- 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
- 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过。
- 成员变量:在使用前,都经历过默认初始化赋值
-
参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配
-
成员变量有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值
-
和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用
补充说明
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
5.4 操作数栈
-
每一个独立的栈帧中除了包含局部变量表以外,还包含一个 后进先出(Last - In - First -Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)
-
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈,
- 比如:执行复制、交换、求和等操作
-
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
-
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这时方法的操作数栈是空的
-
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值
-
栈中的任何一个元素都是可以任意的Java数据类型
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
-
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。只不过操作数栈是用数组这个结构来实现的而已
-
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
-
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
-
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
5.5 代码追踪
- 代码
public void testAddOperation() {
//byte、short、char、boolean:都以int型来保存
byte i = 15;
int j = 8;
int k = i + j;
}
- 对应的字节码指令
0 bipush 15
2 istore_1
3 bipush 8
5 istore_2
6 iload_1
7 iload_2
8 iadd
9 istore_3
10 return
-
执行流程解析
-
首先执行第一条语句,PC寄存器指向的是0,也就是指令地址为0,然后使用bipush让操作数15入操作数栈
-
执行完后,PC寄存器往下移,指向下一行代码,下一行代码就是将操作数栈的元素存储到局部变量表1的位置(istore_1),可以看到局部变量表的已经增加了一个元素。并且操作数栈为空了
-
解释:为什么局部变量表索引从 1 开始,因为该方法为实例方法,局部变量表索引为 0 的位置存放的是 this
-
然后PC下移,指向的是下一行。让操作数8也入栈,同时执行store操作,存入局部变量表中
- 然后从局部变量表中,依次将数据放在操作数栈中,等待执行 add 操作, iload_1:取出局部变量表中索引为1的数据入操作数栈
- 然后将操作数栈中的两个元素执行相加操作,并存储在局部变量表3的位置
- 类型转换说明
- 因为8 可以存放在byte类型中,所以压入操作数栈的类型为byte,而不是int,所以执行的字节码指令为 bipush 8 ,但是存储在局部变量的时候,会转成int类型的变量:istore_4
- m改成800之后,byte存储不了,就成了short型,sipush 800
- 如果被调用的方法带有返回值,返回值入操作数栈
public int getSum(){
int m = 10;
int n = 20;
int k = m + n;
return k;
}
public void testGetSum(){
//获取上一个栈桢返回的结果,并保存在操作数栈中
int i = getSum();
int j = 10;
}
- getSum方法字节码指令,最后带个ireturn
- testGetSum()方法字节码指令,一上来就加载getSum方法的返回值
5.6 栈顶缓存技术
- 基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数(也就是你会发现指令很多)和导致内存读/写次数多,效率不高
- 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
- 寄存器的主要优点:指令更少,执行速度快,但是指令集(也就是指令种类)很多
5.7 动态链接
- 动态链接(或指向运行时常量池的方法引用)
- 每一个栈帧内部都包含一个指向运行时常量池中该帧所属方法的引用。包含这个应用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如invokeddynamic指令
- 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
public class DynamicLinkingTest {
int num = 10;
public void methodA() {
System.out.println("methodA()....");
}
public void methodB() {
System.out.println("methodB()....");
methodA();
num++;
}
}
- 对应的字节码文件:
Classfile /E:/study/out/production/study/study/JVM/DynamicLinkingTest.class
Last modified 2022年4月11日; size 696 bytes
MD5 checksum 1aaa8be9fc34dac046353b43958a276f
Compiled from "DynamicLinkingTest.java"
public class study.JVM.DynamicLinkingTest
minor version: 0
major version: 52
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #8 // study/JVM/DynamicLinkingTest
super_class: #9 // java/lang/Object
interfaces: 0, fields: 1, methods: 3, attributes: 1
Constant pool:
#1 = Methodref #9.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #8.#24 // study/JVM/DynamicLinkingTest.num:I
#3 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#4 = String #27 // methodA()....
#5 = Methodref #28.#29 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = String #30 // methodB()....
#7 = Methodref #8.#31 // study/JVM/DynamicLinkingTest.methodA:()V
#8 = Class #32 // study/JVM/DynamicLinkingTest
#9 = Class #33 // java/lang/Object
#10 = Utf8 num
#11 = Utf8 I
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 LocalVariableTable
#17 = Utf8 this
#18 = Utf8 Lstudy/JVM/DynamicLinkingTest;
#19 = Utf8 methodA
#20 = Utf8 methodB
#21 = Utf8 SourceFile
#22 = Utf8 DynamicLinkingTest.java
#23 = NameAndType #12:#13 // "<init>":()V
#24 = NameAndType #10:#11 // num:I
#25 = Class #34 // java/lang/System
#26 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#27 = Utf8 methodA()....
#28 = Class #37 // java/io/PrintStream
#29 = NameAndType #38:#39 // println:(Ljava/lang/String;)V
#30 = Utf8 methodB()....
#31 = NameAndType #19:#13 // methodA:()V
#32 = Utf8 study/JVM/DynamicLinkingTest
#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 (Ljava/lang/String;)V
{
int num;
descriptor: I
flags: (0x0000)
public study.JVM.DynamicLinkingTest();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 10
7: putfield #2 // Field num:I
10: return
LineNumberTable:
line 3: 0
line 4: 4
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lstudy/JVM/DynamicLinkingTest;
public void methodA();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String methodA()....
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 6: 0
line 7: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lstudy/JVM/DynamicLinkingTest;
public void methodB();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String methodB()....
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: invokevirtual #7 // Method methodA:()V
12: aload_0
13: dup
14: getfield #2 // Field num:I
17: iconst_1
18: iadd
19: putfield #2 // Field num:I
22: return
LineNumberTable:
line 9: 0
line 10: 8
line 11: 12
line 12: 22
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 this Lstudy/JVM/DynamicLinkingTest;
}
SourceFile: "DynamicLinkingTest.java"
分析:
- 在字节码指令中,methodB()方法通过invokevirtual #& 指令调用了方法A,那么#7到底是个啥呢?
- 在上面常量池的定义中找到 #7 = Methodref #8.#31
- 先找#8:
- #8 = Class #32:去找#32
- #32 = Utf8 study/JVM/DynamicLinkingTest
- 通过#8找到了DynamicLinkingTest这个类
- 再找#31:
- #31 = NameAndType #19:#13 :去找#19和#13
- #19 = Utf8 methodA:方法名为methodA
- #13 = Utf8 ()V:反复没有形参,返回值为void
- 结论:通过#7就能找到需要调用的methodA()方法,并进行调用
- 在上面其实还有很多符号引用,比如:Object,System,PrintStream等
为什么要用常量池呢?
- 因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,然后记录其引用即可,节省了空间。
- 常量池的作用:就是为了提供一些符号和常量,便于指令的识别
5.8 方法的调用
5.8.1 静态链接与动态链接
- 在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关
- 静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期确定,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
- 动态链接: 如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
5.8.2 早期绑定与晚期绑定
- 静态链接和动态链接对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
- 晚期绑定:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
class Animal {
public void eat() {
System.out.println("动物进食");
}
}
interface Huntable {
void hunt();
}
class Dog extends Animal implements Huntable {
@Override
public void eat() {
System.out.println("狗吃骨头");
}
@Override
public void hunt() {
System.out.println("捕食耗子,多管闲事");
}
}
class Cat extends Animal implements Huntable {
public Cat() {
super();//表现为:早期绑定
}
public Cat(String name) {
this();//表现为:早期绑定
}
@Override
public void eat() {
super.eat();//表现为:早期绑定
System.out.println("猫吃鱼");
}
@Override
public void hunt() {
System.out.println("捕食耗子,天经地义");
}
}
public class AnimalTest {
public void showAnimal(Animal animal) {
animal.eat();//表现为:晚期绑定
}
public void showHunt(Huntable h) {
h.hunt();//表现为:晚期绑定
}
}
- invokespecial 体现为早期绑定
- invokevirtual 体现为晚期绑定
- invokeinterface 体现为晚期绑定
5.8.3 多态与绑定
- 随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。
- Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。
5.8.4 虚方法与非虚方法
-
区别
- 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法
- 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
- 其他方法称为虚方法
-
子类对象的多态的使用前提:
- 类的继承关系
- 方法的重写
5.8.5 虚拟机中调用方法的指令
- 普通指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用init()方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
- 动态调用指令
- invokedynamic:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预。而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
举例
class Father {
public Father() {
System.out.println("father的构造器");
}
public static void showStatic(String str) {
System.out.println("father " + str);
}
public final void showFinal() {
System.out.println("father show final");
}
public void showCommon() {
System.out.println("father 普通方法");
}
}
public class Son extends Father {
public Son() {
//invokespecial
super();
}
public Son(int age) {
//invokespecial
this();
}
//不是重写的父类的静态方法,因为静态方法不能被重写!
public static void showStatic(String str) {
System.out.println("son " + str);
}
private void showPrivate(String str) {
System.out.println("son private" + str);
}
public void show() {
//invokestatic
showStatic("baidu.com");
//invokestatic
super.showStatic("good!");
//invokespecial
showPrivate("hello!");
//invokespecial
super.showCommon();
//invokevirtual
showFinal();//因为此方法声明有final,不能被子类重写,所以也认为此方法是非虚方法。
//虚方法如下:
/*
invokevirtual 你没有显示的加super.,编译器认为你可能调用子类的showCommon(即使son子类没有重写,
也会认为),所以编译期间确定不下来,就是虚方法。
*/
showCommon();
info();
MethodInterface in = null;
//invokeinterface
in.methodA();
}
public void info() {
}
public void display(Father f) {
f.showCommon();
}
public static void main(String[] args) {
Son so = new Son();
so.show();
}
}
interface MethodInterface {
void methodA();
}
5.8.6 关于invokedynamic指令
- JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现【动态类型语言】支持而做的一种改进
- 但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式
- Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器
@FunctionalInterface
interface Func {
public boolean func(String str);
}
public class Lambda {
public void lambda(Func func) {
return;
}
public static void main(String[] args) {
Lambda lambda = new Lambda();
Func func = s -> {
return true;
};
lambda.lambda(func);
lambda.lambda(s -> {
return true;
});
}
}
5.8.7 动态语言和静态语言
- 动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言
- 说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征
Java:String info = “mogu blog”; (Java是静态类型语言的,会先编译就进行类型检查)
JS:var name = “shkstart”; var name = 10; (运行时才进行检查)
Python: info = 130.5 (运行时才检查)
-
Java 语言中方法重写的本质
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验。
- 如果通过则返回这个方法的直接引用,查找过程结束
- 如果不通过,则返回java.lang.IllegalAccessError 异常
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
-
IllegalAccessError介绍
- 程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
- 比如,你把应该有的jar包放从工程中拿走了,或者Maven中存在jar包冲突
虚方法表
- 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)来实现,非虚方法不会出现在表中。使用索引表来代替查找。【上面动态分派的过程,我们可以看到如果子类找不到,还要从下往上找其父类,非常耗时】
- 每个类中都有一个虚方法表,表中存放着各个方法的实际入口
- 虚方法表是什么时候被创建的呢?虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的虚方法表也初始化完毕
例1:
如图所示,如果类中重写了方法,那么调用的时候,就会直接在该类的虚方法表中查找
- 比如说Son在调用toString的时候,Son没有重写过,Son的父类Father也没有重写过,那么就直接调用Object类的toString,那么就直接在虚方法表里指明toString直接指向的Object类。
- 下次Son对象再调用toString就直接去找Object ,不用先找Son再找Father最后才找到Object这样一个过程
例2:
public class VirtualMethodTable {
public static void main(String[] args) {
}
}
interface Friendly{
void sayHello();
void sayGoodBye();
}
class Dog{
public void sayHello(){
}
public String toString(){
return "Dog";
}
}
class Cat implements Friendly{
@Override
public void sayHello() {
}
@Override
public void sayGoodBye() {
}
protected void finalize(){
}
}
class CockerSpaniel extends Dog implements Friendly{
@Override
public void sayHello() {
super.sayHello();
}
@Override
public void sayGoodBye() {
}
}
5.9 方法返回地址
5.9.1 概述
-
有些地方将方法返回地址、动态链接、一些附加信息称为帧数据区
-
存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
-
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息
-
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去
-
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
5.9.2方法退出的方式
当一个方法开始执行后,只有两种方式可以退出这个方法
正常退出
- 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
- 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
- 在字节码指令中,返回指令包含:
- ireturn:当返回值是boolean,byte,char,short和int类型时使用
- lreturn:Long类型
- freturn:Float类型
- dreturn:Double类型
- areturn:引用类型
- return:返回值类型为void的方法、实例初始化方法、类和接口的初始化方法
异常退出
- 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。
- 方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
- 异常处理表:
- 反编译字节码文件,可得到 Exception table
- from :字节码指令起始地址
- to :字节码指令结束地址
- target :出现异常跳转至地址为 11 的指令执行
- type :捕获异常的类型
5.10 一些附加信息
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。
5.11 栈相关面试题
举例栈溢出的情况?
SOF(StackOverflowError),栈大小分为固定的,和动态变化。如果是固定的就可能出现StackOverflowError。如果是动态变化的,内存不足时就可能出现OOM
调整栈大小,就能保证不出现溢出么?
不能保证不溢出,只能保证SOF出现的几率小
分配的栈内存越大越好么?
不是,一定时间内降低了OOM概率,但是会挤占其它的线程空间,因为整个虚拟机的内存空间是有限的
垃圾回收是否涉及到虚拟机栈?
不会
方法中定义的局部变量是否线程安全?
-
具体问题具体分析
- 如果只有一个线程才可以操作此数据,则必是线程安全的。
- 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
-
具体问题具体分析:
- 如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的,反之则是线程不安全的。
/**
* 面试题:
* 方法中定义的局部变量是否线程安全?具体情况具体分析
*
* 何为线程安全?
* 如果只有一个线程才可以操作此数据,则必是线程安全的。
* 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
*/
public class StringBuilderTest {
int num = 10;
//s1的声明方式是线程安全的(只在方法内部用了)
public static void method1(){
//StringBuilder:线程不安全
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
//...
}
//sBuilder的操作过程:是线程不安全的(作为参数传进来,可能被其它线程操作)
public static void method2(StringBuilder sBuilder){
sBuilder.append("a");
sBuilder.append("b");
//...
}
//s1的操作:是线程不安全的(有返回值,可能被其它线程操作)
public static StringBuilder method3(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1;
}
//s1的操作:是线程安全的(s1自己消亡了,最后返回的只是s1.toString的一个新对象)
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(() -> {
s.append("a");
s.append("b");
}).start();
method2(s);
}
}