JVM原理

159 阅读12分钟

JVM是Java Virtual Machine的缩写。它是一种基于计算设备的规范,是一台虚拟机。正是因为它,能够把我们编写的代码编译成可执行的字节码,这就是平台无关性的基础。即使在具体的操作系统上,仍是要把字节码成具体操作系统的指令。

并且JVM并不只是为Java服务的,只要能够生成字节码也能具有类似Java的这种特性,比如最近热门的Kotlin。Android虚拟机其实也是Java虚拟机的一种衍生。

#JRE和JDK

  • JRE
    Java Runtime Environment,也就是Java运行环境。可以类比一下虚拟机,JRE就可以类比是你Windows电脑下的VMWare+CentOS

  • JDK
    Java Develop Kit,也就是Java开发工具包。一般的,比如Java的java.util.HashMap等等

#内存管理

##JVM区域划分

先讲一下内存区域的划分:

Java虚拟机把内存分为很多数据区域,不同的区域用途和生存周期不同。我们常常直接接触到的是运行时数据区,可以细分为:程序计数器方法区虚拟机栈本地方法栈

  • 程序计数器 相当于一个程序执行过程中的行号指示器,指向当前执行的虚拟机字节码地址。如果执行的是Java方法,计数器就记录着正在执行的虚拟机字节码指令的地址。如果是native 方法,计数器为空
  • 虚拟机栈 是java方法的内存模型,每一个线程在执行时会有自己的一个虚拟机栈,在运行过程中把所调用方法封装为一个栈帧,然后将栈帧存放在栈里面。栈帧包含了一个方法执行时的相关信息,包括方法用到的局部变量,操作数,动态链接等。
  • 本地方法栈 类似于虚拟机栈,只不过他存放的是Native方法。
  • 是相对来说占内存最大的一块,用来存放所有线程创建的类的对象实例。方法调用中如果创建了对象,会把这个对象实例存放在堆中。对于这个对象的引用则存放在栈中,这样就可以在方法中引用对象了。对于内存的回收,也就是对堆内存的回收了。
  • 方法区 存放虚拟机加载的类的信息和一些常量、静态变量等,这些内容一般是不可变的。

其中,方法区是所有线程共享的,所有线程都可以访问

程序计数器虚拟机栈本地方法栈是线程隔离的,每个线程有自己独立的区域,线程之间是不共享的

##OOM和StackOverflow

前面讲到,虚拟机栈会把每次调用的方法封装为一个栈帧存起来。这些栈帧肯定是要占内存的,而栈的内存也是有限的。如果栈帧很多一直没有释放,这时候又来了一个栈帧,这个栈帧已经没有空间可以容纳了,有两种情况。如果这种虚拟机栈不支持动态扩展,那么将会抛出StackOverFlow异常。如果支持动态扩展,那么这个栈会请求再扩展部分空间。当然内存不是无穷的,如果频繁的扩展内存,以至于无法再继续扩展了,这时候会抛出OutOfMemory异常。

除此之外,堆的空间也是有限的。由于创建的对象都是要在堆中分配内存,那么如果堆中空间不足,没有足够的内存空间用来给新的对象分配内存,这时候也会抛出OutOfMemory异常。

##内存分配步骤

创建一个对象,就需要在堆中给这个内存分配一块内存。当对象不再被使用,所占的内存就被回收,用来分配给其他。

一个对象的创建往往需要以下四步:

  1. 虚拟机发现创建对象指令后(比如new 对象),会先看看new 后面跟着的那个参数能否在常量池中定位到一个类的符号引用,并且检查那个类是否已经被加载过。如果没有,则进行一次类的加载工作(具体细节后面会讲)。
  2. 加载完成后,虚拟机会为新的对象在堆中分配一块内存,具体分配多少,在类加载完之后其实就已经定了。分配完内存,之后会将这个对象的实例字段初始化为零值。
  3. 之后对对象进行一些设置,比如设置哈希码,分代年龄信息,这个对象属于哪个类之类的。
  4. 最后,调用相关代码,按照我们的代码逻辑做一次初始化。

创建好一个对象,还需要一个引用来持有,我们才能使用。引用是放在虚拟机栈栈帧的本地变量表中的。引用有两种形式,一种是直接持有对象地址,一种是持有一个句柄,句柄保存在堆中,包含着对象的地址,是间接访问。直接访问速度快,间接访问在对象频繁移动时比较有优势。

##内存回收

###哪些对象会被回收?——GC root

目前虚拟机的主流回收算法有两种引用计数法可达性分析算法,而JVM就采用了可达性分析算法。

引用计数法: 每个对象都有一个引用数。每有一个对象的引用,引用数就+1,如果没有被任何引用,那么它引用数就为0,表示为可回收。但有个问题就是如果两个对象相互引用,不就永远不会被回收了

可达性分析算法: 将一些特定的对象作为GC Roots,然后从这个节点向下寻找对其他对象的引用。如果一个对象到GC Roots没有引用链,那么就可以被回收了。在Java虚拟机中,被规定作为GC Roots的对象有:

  • 虚拟机栈中引用的对象
  • 方法区中 静态属性引用的对象
  • 方法区中 常量引用的对象
  • JNI引用的对象

所以我们日常开发过程中遇到的内存泄漏,很大一部分原因就是本该被回收的对象无意之中被GC Roots引用到了,比如写的static这样的静态字段引用的对象,这样他就不会被回收了。

###如何回收?——混合算法

虚拟机针对对内存回收,根据对象的生存周期把堆分为了两个区:新生代老年代

新生代又分为一个Eden区和两个Survivor区。每次分配内存,如果对象比较大的话直接进入老年代。否则,先进入Eden区和一个Survivor区,同时会为每一个对象设一个年龄值。之后会周期性的在某个安全点检查一下,对于新生代的对象,将可回收的对象回收掉,将剩余的对象复制到另一个Survivor区,这一过程中会对年龄值加一。这一过程叫做Minor GC,是属于新生代的GC。当某些对象年龄值比较大时,会将他们移动到老年代去。当然在这之前会先查看一下老年代剩余空间是否满足移动。如果不能满足,就会对老年代进行一次GC,这一过程叫做Full GC。而这个检查对象是否可GC得时机,也就是GC的时机,一般是确定的被称作“安全点”。在这一时机进行检查,是不会影响程序正常运行的。

在新生代中因为每次都会有大量对象被回收,比较频繁,因此采用了复制算法。而老生代相对来说回收的对象少,没那么频繁,而且对象普遍比较大,因此采用了标记-清除标记-整理算法

###灵活的控制——四大引用

GC的流程大致就是这样。我们知道Java中引用有四种,分别是强、软、弱、虚。这四种引用的区别就在于GC的过程中:

  • 强引用: 被强引用的对象,一般是不会被回收掉的。使用:直接通过类名 new 一个对象
  • 软引用: 被软引用持有的对象,只有在“不回收就要内存溢出”的时候,才会回收。
  • 弱引用: 被弱引用持有的对象,在每次GC都会被回收
  • 虚引用: 无任何时机作用,只是一个标记,为了能使对象被回收时做一些系统通知什么的

#类加载机制

##类加载的步骤——五大步骤

类加载分为:加载->链接->初始化
其中链接又可以分为:验证、准备、解析:

  • 加载 是类加载的第一阶段,主要做以下三件事:
    • 通过一个类的全限定名来获取定义此类的二进制字节流
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    • 在内存中生成一个代表这个类的class对象,作为方法区这个类的各种数据访问入口

加载这个过程是最可控的阶段,开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。所以衍生出了很多新东西,比如Jar包的读取,从网络中加载类等。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

  • 验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

    • 文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
    • 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。
    • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
    • 符号引用验证:确保解析动作能正确执行。
  • 准备 正式为类量分配内存并设置初值。类变量要分配在方法去中,设置初值的是类变量而不是实例变量

  • 解析 将常量池内的符号引用替换为直接引用。前面说过,符号引用只是以简单的通过名称等信息指出引用的方法或类,。那么在这里才会真正的将符号引用转换为直接引用,即对于方法区类的引用。直接引用类似于指针,所以这一过程可以理解为从名称到地址的转化

  • 初始化 前面是加载和链接的过程,这里就是类加载过程的最后一步了。所谓的初始化阶段,就是真正执行在类中写的代码了。比如实例变量的初始化、静态代码块、构造器等。初始化阶段也可以理解为调用类的构造器的过程

##类加载的时机——按需加载

类加载是按需加载,也就是什么时候用什么时候加载,下面四种常见情况:

  • new一个对象,或者调用一个类的静态字段或者静态方法
  • 反射调用一个类
  • 子类加载前要先加载父类
  • 虚拟机刚启动时执行主类

##类加载的工具——类加载器

前面讲到第一步“加载”的过程,要通过一个类的全限定名来获取这个类的二进制字节流。这个过程,是要借助于虚拟机外部的工具来进行的,这一工具就是类加载器。每一个类,都有一个针对他的类加载器。两个类是否相同,不但要比较他本身,还要比较他们的类加载器。

类加载器可以分为三类:

  • 启动类加载器(Bootstrap ClassLoader):由C++编写,属于虚拟机的一部分,是属于很基础的加载器,会加载Java目录下lib中的类
  • 扩展类加载器(Extension ClassLoader):负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar 或 -Djava.ext.dirs指定目录下的jar包
  • 应用类加载器(App ClassLoader):也叫做系统类加载器,加载用户类路径上自己指定的类,我们平时使用也基本是使用这个
  • 自定义加载器(Custom ClassLoader):通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader

而具体的加载逻辑,被称为“双亲委派模型”。具体流程:

  • 首先从底向上的检查类是否已经加载过,也就是从Custom ClassLoader到BootStrap ClassLoader逐层检查,防止重复加载。
  • 如果都没有加载过的话,为了防止内存中存在多份同样的字节码,就自顶向下的尝试加载该类,也就是从BootStrap ClassLoader到Custom ClassLoader逐层检查,防止java核心类被修改