学习内容来自尚硅谷宋红康老师的JVM教程
https://www.bilibili.com/video/BV1PJ411n7xZ/
类的加载过程
加载通过一个类的全类名获取该类的字节流 将字节流代表的静态结构转换为方法区的运行时数据结构 在内存中生成一个Class对象,作为这个类的各种数据的访问入口 链接阶段验证:验证字节流中的信息满足虚拟机要求,如cafebabe头 准备:为类变量分配内存,类的赋值默认初始值(非final的静态变量) 解析:将常量池中的符号引用转换为直接引用的过程 初始化:执行类构造器方法clinit方法(如给静态变量进行显式初始化, 静态代码块执行),子类运行前需要先加载父类的clinit方法,该方法是同步的。
类的加载器
两种类型,引导类加载器(Bootstrap Classloader)和自定义类加载器(拓展类加载器,系统类加载器,用户自定义加载器)
引导类加载器在代码中获取不到,使用c、c++实现的,嵌套在jvm内部,主要加载一些java的核心类库,也会加载拓展类加载器和系统加载器 拓展类加载器加载,父类加载器就是引导类加载器,在ClassLoader继承体系下,加载jre/lib/ext下面的jar包 应用程序类加载器(系统类加载器), 父类加载器就是引导类加载器 , 在ClassLoader继承体系下, 是默认的类加载器,我们在代码中写的类都是这个加载的。
用户自定义类加载器在一些特殊情况下使用
双亲委派机制
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行 如果父类的加载器还存在父类的加载器,则进一步加上委托依次递归请求最终达到顶层的启动类加载器, 如果父类加载器可以完成内加载任务就成功返回,倘若腹内加载器都无法完成此任务,此类加载器才会自己尝试去加载,这就是双亲委派模型
反向委派,核心类库是接口,外部jar包是实现类。
优势:避免类的重复加载,保护程序安全,防止核心的API被修改
沙箱安全机制,就是双亲委派机制对核心源代码的保护,不能再核心类库包名下定义main方法。
运行时数据区
本地方法栈、虚拟机栈、程序计数器、堆区、元数据区(方法区/非堆空间)
线程有自己的程序计数器、虚拟机栈、本地方法栈
多个线程共享堆区和方法区(进程独占,生命周期同JVM)
一个JVM实例对应着一个Runtime类的对象。
程序计数器(PC寄存器)
主要用来存储指向下一条指令的地址,也就是即将执行的指令代码,由执行引擎读取下一条指令
线程私有的,生命周期和线程同步,占用内存很小,不会发生OOM错误而且没有GC。
PC寄存器存储地址有什么用呢?因为CPU会不停的切换各个线程,当切换为某个线程之后,就要知道程序从哪里继续执行。
虚拟机栈
JAVA指令是根据栈来设计的,这样的优点是可以跨平台,指令级比较小,编译器容易实现,但是缺点是和基于寄存器的指令性能实现了一定的下降,同样的功能也需要更多的指令。
栈是运行时单位,堆是存储的单位。
虚拟机栈里面存数的是很多的栈桢 stack frame,每一个栈桢 对应着一个方法的 。栈顶的栈桢对应着当前方法。
栈是一种快速有效的分配存储方式;访问速度仅次于PC计数器;操作只有两个 ,入站、出站;栈不存在垃圾回收问题.
StackOverFlowError请求的容量超过了虚拟机占允许的最大容量(无限递归)
OutofmemoryError动态拓展虚拟机内存的时候无法申请到足够的内存
JVM栈可以在启动的时候制定大小。
栈中存什么,栈桢(一个内存区块),栈桢和方法是一一对应的关系
当前栈桢–当前方法–当前类
方法的结束方式有两种,第一种正常结束return;第二种方法中出现未捕获的异常,以抛出异常结束。因此有可能栈顶是异常结束,栈底正常结束,因为栈底处理了异常。
每个栈桢的结构有:局部变量表、操作数栈 、动态链接、方法返回地址、一些附加信息
局部变量表 是一个数字数组,存方法参数和方法体内的局部变量,包括基本数据类型、引用数据类型和返回值类型。不存在安全问题。局部变量表的大小是在编译时期确定的。
局部变量表中的slot(除了long 和double占用两个slot(32bit 一个 slot),其他类型的数据或者引用占用一个slot);成员方法的局部变量表中的第一个元素是this的引用。slot重复利用,作用域小的销毁后重复利用。
成员变量都有默认初始化的过程,而局部变量没有,所以局部变量必须显式地赋值。
操作数栈 主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间,
在方法执行过程中,根据字节码指令往栈中写入数据或提取数据即入栈或出栈
说操作数栈实际底层肯定是要用具体的数据结构来实现的,然后可以用数组或者列表,这里用的数组。所以需要指定数组的大小,编译时候确定。
动态链接 (指向运行时常量池(这个常量池是字节码文件内部的,最终也会放到常量池中)的方法引用)的作用就是将符号引用转换为调用方法的直接引用
为什么需要常量池?提供一些符号和引用便于指令识别,减小字节码文件的冗余,多个字节码文件可以共用方法区的常量池。
方法调用是怎么样的?方法调用也有静态链接和动态链接对应早期绑定和晚期绑定。就是多态。
Lambda表达式引入入让java这种静态类型语言具有了动态类型语言的一些特性以及底层是用invokeDynamic指令来实现的。用值判断变量的类型,而不是用变量类型来约束值的类型是动态类型语言的关键。
虚方法和非虚方法
虚方法如果每次都需要不断向上层父类找实现类,效率会比较低。为了提高性能,建立虚方法表 。虚方法在类加载的链接阶段被创建(具体是在解析的阶段)
方法返回地址 调用者的 pc计数器的值 作为返回地址,即调用方法之后的下一条指令地址。
方法如果正常退出,则正常返回调用者的方法返回地址,如果是异常退出,调用者则根据异常表来定位接下来执行的位置。
一些附加信息 不重要
一些面试题:
栈溢出的情况:stackOverflowError
调整栈的大小,能保证不一出吗?否
垃圾回收设计JVM栈吗?否
分配栈的内存越大越好吗?否
自定义的局部变量是否线程安全?不一定
如果方法的形参是引用类型的变量或者返回值是引用参数类型,则可能是线程不安全的
本地方法接口和本地方法库 —–非运行时区的模块
本地方法:Native Method就是一个Java调用非Java代码的接口。为什么要用本地方法?
Java应用需要与Java外界环境交互,效率考量 Java需要与操作系统的交互,调用c语言实现的接口
本地方法栈
类似于虚拟机栈的作用,就是管理本地方法的调用,也是线程私有的,内存溢出差不多,有StackOverflow和OutOfMemory,也可以设置栈的内存大小。
堆
一个JVM实例只有一个堆内存,堆也是java内存管理的核心区域。Java堆积在juem启动的时候即被串件,其空间大小也就确定了,是JVM 管理最大的一块内存空间。所有的线程共享堆空间(但是堆中可能有线程的私有缓冲区TLAB)
几乎所有的对象实例和数组都在堆空间中,对象的引用在JVM栈中的某个栈帧的局部变量表中。方法运行结束的时候,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
堆是垃圾回收的重点区域。
现在垃圾收集器大部分都基于分代收集理论 设计
JDK7以及之前:新生区+养老区+永久区 JDK8以及之后:新生区+养老区+元空间 永久区/元空间可以理解为方法区。主要先关注新生区和养老区
堆可以手动设置堆空间的大小,默认是电脑内存的1/64,最大的堆内存大小是电脑内存的1/4。建议初始和最大的设置成一样的。
OOM Error 堆中的对象所占的空间超过了堆的大小,则会导致堆空间的溢出。
年轻代和老年代(二者的内存比例可以分配,默认值是1:2)
年轻代有分为:伊甸园区、幸存者1区和幸存者2区(默认比例是8:1:1,但是需要显式指定)
几乎所有的java对象都是在Edan区创建的,大多数的数据都在青年代被销毁。
创建对象的过程,创建的对象默认放在edan区,当edan区满的时候,触发gc,不是垃圾的全部到to区,from区的也会被gc检查,不是垃圾也到to区。 from区和to区的数据会维护一个年龄,每次gc之后年龄+1,超过阈值的时候移动到老年区。 from区或者to区满的时候不会触发gc,只有edan区满的时候会,当伊甸园区来的对象to区放不下的时候,可以直接放到老年区。 如果老年区也放不下,在老年区执行一次GC,如果还是放不下就报错OOM。
YGC就是MinorGC,MajorGC是OGC/老年代的GC。
GC线程执行会导致用户进程的暂停,所以GC要尽可能少。
分代 的原因就是通过统计数据优化垃圾收集器的性能 。因为大部分的对象的生命周期都比较短。
TLAB Thread Local Allocation Buffer
堆是线程共享数据,但是存在线程安全问题,加锁会导致效率的下降,因此未每个线程在伊甸园区分配私有缓存区域。(通常这个空间比较小,为伊甸园区的1%。)
伊甸园区过大,会降低MinorGC的效率,过小会导致MinorGC触发的频率过高,影响用户线程,所以设置要平衡一下。
堆是分配对象存储的唯一选择吗?(”是“)
逃逸分析后,如果未逃逸,则可以将对象优化成被栈上分配。
如果new的对象可以在方法外被进行调用,则发生了逃逸。(就是一个作用域的问题)
栈上分配可以在某种程度上来说,加快代码的执行效率,并减少垃圾回收执行的次数,增强用户线程的体验。 同步消除,如果锁通过逃逸分析,仅仅在当前线程被使用,那么不存在同步安全问题,可以消除同步操作。 标量替换,未逃逸的对象,可以从聚合量的形式被打散成标量(基本数据类型),直接在栈上进行分配就可以了。
但是逃逸分析本身也要消耗性能的,总体的提升是有风险的。该技术还不成熟 。
方法区
方法区是线程共享的,逻辑上是堆的一部分,但是在大部分JVM在实现过程中是和堆分开的。jdk7 叫做永久带,jdk8叫做元空间。
方法区和永久代不严格等价,在hotspotJVM上是等价的。
元空间的使用的内存是本地内存,永久代用的是虚拟机内存。
方法区主要存类型信息,常量,静态变量和JIT代码缓存
类信息:
类的完整定义如类声明、继承、实现情况、修饰符等 域信息(成员变量):名称、类型、权限修饰符、顺序 方法信息:方法名称、返回值类型、参数个数类型、方法字节码、本地变量表、操作数栈、异常表……
全局变量和全局常量的区别,全局常量在编译阶段就赋值完成了(在字节码文件中有体现),而全局变量在类加载链接阶段的prepare阶段才默认初始化,在第三各初始化阶段才显示初始化。
运行时常量池:字节码文件中有常量池,方法区中有运行时常量池。
常量池 包括了各种字面量 和对类型、域和方法的符号引用。
为什么要改?
永久代的空间是不好确定,容易产生OOM 对永久代的调优的过程是比较困难的
为什么StringTable要调整?因为永久代回收的效率很低,在full gc 的时候会触发,这导致StringTable回收的效率不高,而日常开发中会有大量的字符串被创建,如果不及时回收,会导致永久代空间不足,放到堆里面,回收会比较即使。
区分好对象本身 一直是在堆区的,而对象引用变量 (存的对象地址)放的位置不同版本有区别。
方法区的垃圾回收主要是常量池中废弃的常量 和不再使用的类型 。
方法区中类的回收条件十分苛刻,费力不讨好。
创建对象的方式
new Class.newInstance Constructor.newInstance clone(实现Cloneable接口以及浅复制) 反序列化
创建对象的步骤:Object obj = new Object();
对应的字节码:
Code:
stack=2, locals=2, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object.””:()V
7: astore_1
8: return
判断对象对应的类是否加载、链接、初始化 为对象分配内存,计算占用空间的大小(确定的)内存规整-指针碰撞(整块空间) 内存不规整-空闲分配列表(碎片化的空间) 处理并发问题 初始化分配到的空间(赋默认初始化值) 设置对象头(所属的类,对象的HashCode,对象的GC信息、锁信息) init方法初始化
对象的内存布局:
对象头Header运行时元数据:哈希值、GC分代年龄、锁状态等 类型指针:指向类元数据 实例数据:从祖宗到自己的各种类型的字段 对齐填充
对象的访问定位:
句柄访问:效率低(间接访问),需要额外内存维护句柄池,栈中的指针稳定, 直接指针(HotSpot采用的方式):效率高,栈中的指针不稳定,节省内存
直接内存
Java Process Memory = Java heap + native memory (其他空间太小了)
执行引擎
解释器和JIT编译器->java是半编译半解释型语言(与javac编译无关)
解释器:效率比较低,但是响应时间快
为什么非要有字节码?不想C语言、C++一步到位?分层思想可以提高各个部分的效率,不用一步到位的考虑,就想OSI网络分层结构一样,应用层不用直接管物理链路层的东西。虽然分层做出了一定性能上的牺牲。
JIT(Just in time)编译器:响应时间慢,但是执行效率高
Java中的编译器:前端编译器、后段编译器
热点代码以及探测方式
JIT编译后的代码缓存在方法区
默认情况下JVM执行引擎是混合模式,可以通过参数制定只运行特定模式。
JIT编译器分为C1(客户端)和C2(服务端)两种