复习:Java虚拟机 JVM底层结构

学习内容来自尚硅谷宋红康老师的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(服务端)两种

发表评论

电子邮件地址不会被公开。 必填项已用*标注