JVM Java虚拟机
概论
Java中JDK是Java开发工具包(写Java代码/编译代码,需要用到的内容)
JRE是Java运行时环境(是运行Java程序需要的内容)
JVM是JRE中的核心模块
虚拟机就是用软件模拟硬件,但Java的虚拟机并不是模拟出电脑的所有核心硬件(一部分),也不能运行独立的操作系统,只能运行Java的代码
JVM更准确地说,是Java语言的运行时环境,核心功能就是把Java的代码翻译成CPU能够识别的机器指令,虽然会降低运行效率,但是提高了整体的开发效率,可以更好的做到“跨平台 ”和硬件无关“,跨平台指的是同一个程序可以放在不同的操作系统上和不同架构的CPU上运行
Java程序是如何运行的
- 编写代码,是编写了.Java文本文件
- 通过javac这样的命令行工具,把.Java编译成.class文件(字节码文件)
- 通过Java这样的命令行工具,运行对应的.class文件
Java命令行工具对应到一个Java进程,这个Java进程就可以理解成一个Java虚拟机
Java代码中抛出异常就是在3阶段产生的
JVM内存区域划分
Java程序跑起来得到Java线程,需要从操作系统申请一大块内存空间,Java进程就需要把这一大块空间分成多个区域,分别赋予不同的功能,简化模型如下:
程序计数器
是一块非常小的空间,只需要保存一个地址,描述当前Java程序要运行的下一个字节码指令的位置一个Java进程中有多个程序计数器,线程是cpu调度执行的基本单位,每个线程都有一个程序计数器来记录,主要作用就是确保多线程切换后能准确恢复执行位置,程序计数器会保存上下文,包括线程执行过程中的所有相关寄存器的值
元数据区
Java中会创建类,再基于类创造对象,对象需要空间保存,类也一样,元数据去保存的就是类对应的指令.class文件通过二进制的方式表示了一个类的基本信息,在Java代码中通过类名.class的方式就能获取到该类对象
栈
这个区域保存了方法调用关系,每个方法调用会创建一个独立栈帧,一个栈帧表示一次方法调用,包含的信息有:
- 方法的参数是哪些
- 方法中的局部变量有哪些
- 方法执行结束之后的返回值结果
- 方法执行结束之后跳转回的地址
堆
堆存放了new出来的对象,成员变量
JVM整体的空间大小中,堆是最大的,远超出其他区域;栈空间比较小,主要取决于代码有多少个线程,每个线程调用方法的层次(如果无限递归就会出现栈溢出异常);元数据区大小中等,取决于代码的规模(类、类方法、属性多不多);程序计数器最小,空间大小固定为8个字节
堆和元数据区是整个进程只有一个;堆和程序计数器是每个线程中有一个,整个进程中有多个
局部变量=>栈 成员变量=>堆 静态成员变量=>元数据区
类加载
类加载指的是JVM从最开始的读取.class文件,到最终完成构造类对象的整个过程,也就是把类从硬盘加载到内存中,类加载有五个步骤:
步骤一:加载
- 根据代码中编写的全限定类名(包名+类名),找到.class文件,找的过程叫做双亲委派模型
- 打开文件,读取文件内容到内存中
- 数据格式的解析
步骤二:验证
校验上述的.class文件读出来的内容是否是合法的,如果验证过程中有问题就需要及时报错
步骤三:准备
给类对象分配内存空间,此处申请的内存空间是一个未初始化的内存空间,空间上的每个字节全都是0,此时类对象中的静态成员也是0
步骤四:解析
针对代码中的常量进行初始化,需要把.class文件中的常量加载到内存中
步骤五:初始化
就进入用户写代码的环节,类的静态成员要执行真正的初始化操作,要针对父类/要实现的接口的加载,如果B继承于A,那么加载B的时候,需要判定A是都已经加载了,如果已经加载,就不必加载B(一个类加载一次),如果未加载,就需要把A也加载
类加载也是懒汉模式,只有某个类第一次用到才会真正触发加载,“用到一个类”指的是:
- 构造某个类的实例
- 调用类的静态方法/使用类的静态成员
- 使用子类的时候,也会触发父类的加载
双亲委派模型
这个模型是在类加载的第一个环节中,根据类的全限定类名找到对应的.class文件的过程,JVM中进行类加载的操作,需要依赖内部的模块
JVM自带了三种类加载器:
- Bootstrap ClassLoader:负责在Java的标准库中进行查找
- ExtensionClassLoader:负责在Java的扩展库中进行查找
- ApplicationClassLoader:负责在Java的第三方库/当前项目进行查找
这三个类加载器之间,存在父子关系,每个类加载器中有一个parent这样的属性,保存了自己的父亲是谁
双亲委派模型的目的就是为了确保三个类加载的优先级,标准库优先加载,第三方库/当前项目类最后加载,找到了就继续进行后续的加载流程,如果最后也没有找到类,就会抛出异常
JVM的垃圾回收机制(GC)
回收内存区域
GC回收的内存区域主要是堆
回收单位
GC回收的基本单位是对象,以对象为维度进行回收更简单方便
如何回收
- 找出垃圾,区分出哪些对象是垃圾
- 释放这些垃圾对象的内存
如何找出垃圾
在Java中使用对象,都是通过引用来进行的,使用对象的属性/方法都需要通过引用,如果一个对象已经没有任何引用指向它了,就说明这个对象无法被使用了;由此就可以将判定一个对象是否是垃圾转换成判定是否有引用指向这个对象
引用计数
给每个对象都分配一个计数器,当引用计数为0,此时就没有任何引用指向,对象就是垃圾了,但这样有两个弊端:
- 消耗额外的内存空间较大
- 循环引用问题,可能会出现AB互相证明对方不是垃圾,但实际上都是垃圾
可达性分析(Java使用的方案)
在Java中,每个可访问的对象一定是可以通过一系列的引用操作访问到的,JVM会安排专门的线程负责上述扫描的过程,会从一些特殊的引用开始扫描(GC roots):
- 栈上的局部变量(引用类型)
- 常量池里指向的对象(final修饰的引用类型)
- 元数据区(静态成员,引用类型)
这三组可能有很多变量,以这些变量为起点,尽可能地往里访问所有可能被访问到的对象,但凡被访问到的对象,都标记为可达,JVM又知道所有的对象列表,去掉被标记为可达的,剩下的就是垃圾,不引入额外的内存空间,但是需要消耗较多的时间,进行上述扫描过程,也容易触发STW(stop the world)
如何释放垃圾(回收内存)
关于内存回收,涉及到几种算法:
标记-清除
标记就是可达性分析,找到垃圾的过程,清除就是释放这部分的内存,但这样的清除可能存在内存碎片问题:释放内存后总的空闲内存空间是比较多的,但是不连续,在申请内存的时候,都是在申请连续的内存空间,这样即使有足够的空间也无法申请成功
复制算法 解决内存碎片
把不是垃圾的对象复制到另外一侧,然后把整个空间都释放掉,很好的解决了内存碎片问题,但是也有弊端,一开始会浪费一半的内存,如果存活的对象比较多/比较大,复制的开销是非常明显的
标记-整理
类似于顺序表删除元素-搬运元素,删除后把存活的对象搬运到一块,就能留出连续的内存空间,但是搬运对象的成本也可能会比较高
分代回收
把整个堆空间,分成新生代和老年代,这里的年龄指的是一个对象经过垃圾回收扫描现成的轮次,可达性分析中,JVM隔一段时间就会扫描一次,不是垃圾就年龄+1,一般来说年龄超过15的就可以进入老年代,对于年轻对象来说,是很容易成为垃圾的,老年对象则不容易成为垃圾,所以针对新生代和老年代不同的特点就可以采用不同的方案
刚创建的对象放到伊甸园,如果活过一轮GC,就进入幸存区,两个幸存区同一时刻只使用一个,相当于复制算法,每次经过一轮GC,就会淘汰掉幸存区中的一大部分对象,把存活下来的对象和伊甸园中新存活下来的对象复制拷贝到另一个幸存区,如果这个对象在新生代中存活多轮之后,就会进入老年代,老年代的对象由于生命周期大概率很长,就没必要频繁扫描;或者这个对象很大,不适合复制算法,就直接进入老年代,老年代回收内存使用的是标记-清除/标记-整理