JVM常用总结1之JVM组成

本文主要从JVM的组成部分以及作用扩展到JVM的内存回收等,大部分是常见面试中经常提到的

JVM的主要组成部分以及作用

JVM包括两个子系统和两个组件,两个子系统为class loader(类加载器),execution engine(执行引擎).两个组件为Runtime data area(运行时的数据区),Native interface(本地接口)

class loader(类加载器)

根据给定的类名(如java.lang.Object)来装载class文件到Runtime data area中的method area方法区.

execution engine(执行引擎)

执行引擎负责执行classes中的指令.

native interface(本地接口)

与本地方法库交互,是其他编程语言交互的接口.

runtime data area 运行时数据区域

也就是我们常说的JVM内存,包括堆,JVM虚拟机栈,程序计数器,本地方法栈,方法区等五部分组成.

jvm作用

首先通过编译器将java代码转换为字节码,类加载器把字节码文件加载到内存中,将其存放在运行时数据区的方法区中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎,将字节码翻译成系统指令,再交由CPU去执行.而在这个过程中,需要调用其他语言的本地库接口来实现整个程序的功能.

类加载器的详解

JAVA类加载机制

java虚拟机把描述类的class文件加载到内存中,对数据进行校验,解析和初始化,最终形成可以被虚拟机直接调用的java类型.

JVM加载class文件的原理机制

java中所有的类都需要类加载器加载到JVM中才能运行,类加载器本身也是一个类,它的工作就是把class文件从硬盘读取到内存中.在写程序中,我们几乎不用关系类的加载,这些都是隐式加载的,除非我们有特殊的用法,比如反射,才需要显示加载所需要的类.

类加载的方式有隐式加载和显示加载两种,隐式加载是指程序在运行过程中碰到new方式生成对象时,隐式调用类加载器加载对应的类到JVM中,显示加载通过class.forName()等方法显示的加载类.

类加载器有哪些

类加载器是一种实现通过根据类名获取该类的二进制字节码文件流的代码块的方式.

类加载器分为四种,分别是

  • 启动类加载器:负责加载JRE的lib目录下的核心类库,比如rt.jar
  • 扩展类加载器:负责加载JRE的lib目录下的ext扩展目录下中的jar包
  • 应用程序加载器/系统类加载器:负责加载classPath路径下的类包,主要是加载自己写的那些类,一般java应用的类都是通过它来完成加载的,可以通过Classloader.getSystemClassLoader()来获取它.
  • 自定义加载器:负载加载用户自定义路径下的类包,通过继承java.lang.ClassLoader类的方式来实现.

类加载的执行过程

类加载主要分为以下五步

  1. 加载:根据类的查找路径找到对应的class文件,然后倒入
  2. 验证:检查加载的class文件的正确性
  3. 准备:给类中的静态变量分配内存空间
  4. 解析:虚拟机将常量池中的符号引用替换为直接引用的过程,比如将main()方法,一些其他静态方法替换为指向数据所存内存的指针或句柄等,也就是静态链接的过程.而动态链接是指的在程序运行期间(非静态方法)完成的将符号引用替换直接引用.
  5. 初始化:对类的静态变量初始化为指定的值,执行静态代码块.

类被加载到方法区中后主要包含运行时常量,类型信息,字段信息,方法信息,类加载器的引用,对应class实例的引用等信息,对应class实例的引用指的是类加载器在加载类信息到方法区中后,会创建一个对应class类型的对象实例放到堆(heap)中,作为开发人员访问方法区中类定义的入口和切入点.

类加载的双亲委派机制

JVM的类加载器是有亲子层级结构的,也就是双亲委派机制,加载某个类时,会先委托其父加载器寻找目标,找不到再委托上层父加载器加载,如果所有的父加载器都在自己的加载路径下找不到该类,则在自己的类加载路径中查找目标类.

比如加载某个我们自定义的类,最先会找应用程序类加载器加载,应用程序类加载器委托扩展类加载器,扩展类加载器委托引导类加载器,顶层的引导类加载器在自己的类加载路径下找不到该类,则向下退回加载类的请求,扩展类继续加载,如果没有找到,则继续向下退回到应用类加载器,应用类加载器在自己的加载路径下能找到该类,则自己加载.

这样设计的双亲委派机制可以保证沙箱安全机制,比如自己写的java.lang.String不会被加载,防止核心API被篡改,同时可以避免类的重复加载,当父类已经加载了该类,就没有必要子类加载器再加载一次,保证被加载类的唯一性.

tomcat是打破了双亲委派机制的,因为一个web容器中可能会部署多个系统,多个系统可能会依赖同一个第三方的jar包,但是jar包的版本不同的话,默认的类加载器是不关心版本的,只关心类名,并且只有一份,同时需要支持JSP文件的热修改部署.

另外JVM的类加载器还有全盘负责委托机制,当一个类加载器加载一个类时,除非显示的使用另外一个类加载器,否则该类所依赖以及引用的类都是这个类加载器加载.

JVM内存模型详解

JVM整体结构及内存模型如下所示:

JVM整体结构

数据运行区域主要包括五部分:

  1. JVM虚拟机栈:用于存储局部变量表,操作数栈,动态链接与方法出口等信息.
  2. 程序计数器:记录当前线程的代码运行位置的内存地址
  3. 本地方法栈:类似于JVM虚拟机栈,只不过是提供给其他编程语言,调用native方法服务的.
  4. 堆:java虚拟机中最大的一块区域,被所有的线程共享,几乎所有的对象实例都在这里分配内存.
  5. 方法区:又叫元空间,用于存储静态变量,类描述,常量等

有代码如下,后续会根据此代码进行解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class JVMTest {

public static final int data = 100;

public static User user = new User();

public int compute() {
int a = 1;
int b = 2;
int c = a*b;
return c;
}

public static void main(String[] args) {
JVMTest jvmTest = new JVMTest();
jvmTest.compute();
}
}

java虚拟机栈

虚拟机会给每个线程分配一个内存空间,也就是栈内存空间,不同的线程都有自己独立的内存空间,而单个线程中的每个方法都有自己独立的栈帧内存空间.
比如main()方法和compute()方法就是两个独立的栈帧内存空间.栈的入栈和出栈都是在栈顶做的,当执行以上方法时,首先是在压入一个main()方法的栈帧,然后在压入compute()方法的栈帧,当程序执行完compute()后,进行释放空间时,也就比如的先释放compute()的内存地址,然后main方法执行完再释放main()方法的内存地址,也就是先进后出,保证内存的正确释放.

每个栈帧中包括了局部变量表,操作数栈,动态链接,方法出口等信息.

如以上的compute()方法,int a=1首先是在操作数栈中压入一个为1的数据,然后在局部变量表中定义一个变量a,接下来将操作数栈中的1取出来赋值给变量a,此时操作数栈中没有数据,局部变量表中定义为a=1.int b=2 与此类似,在执行int c = a*b时,从局部变量表中取出a和b的值,压入操作数栈,接下来将1和2从操作数栈中取出来进行计算相乘,将结果压入操作数栈,最后在局部变量表中定义变量c,将操作数栈中的相乘结果取出来赋值给变量c.

动态链接指的是将放到常量池中的符号引用转换为对应方法区的内存地址,比如main()方法中的jvmTest.compute()这行代码,compute()方法本身有内存地址,而main()方法中存放的仅仅是该方法内存地址的引用.与静态链接相比,动态链接是在方法运行期间完成的.

方法出口指的是记录方法执行完后,回到main()方法的执行位置的记录.

程序计算器

程序计数器是每个线程独有的,记录的是当前线程字节码执行的行号.字节码解析器就是通过改变这个计数器的值,来选取下一行要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等都需要程序计数器来完成.

本地方法栈

也是每个线程独有的,虚拟机栈是服务java方法的,而本地方法栈是为虚拟机调用native方法服务的

方法区

存放常量,静态变量,类信息等
比如以上代码中的常量data,静态变量user就是存放在方法区.
static User user = new User()会在堆中分配地址来存放该对象,同时在方法区存放指向该对象内存地址的指针引用.

堆是JVM虚拟机中内存最大的一块区域,堆存放的是对象的实例和数组,该区域关注的是数据的存储.
所谓的内存分配通常指的是在Java堆上分配的,根据分代垃圾回收期,主要有两个分区,新生代和老年代,空间占比分别为1/3,2/3.新生代中又分为eden,from survivor,to survivor,空间占比为8:1:1.

多数情况下,对象都在新生代eden区分配,当eden区没有足够空间分配时,虚拟机会发起一次minor gc,判断该对象如果是不可以被回收,则放到from区,如果可以回收,则直接回收掉.
当下一次进行minor gc时,会将eden区和from区中的存放对象放到to区,同时清空这两个分区,下一次的时候,则放入to区,实现from区和to区在每次minor gc的分区交换.

每次在from区和to区移动时都存活的对象,年龄+1,当达到默认值15时,则升级为老年代.
当老年区的空间占用达到某个值后,就会触发全局垃圾回收,也就是major gc/full gc

大对象数据会直接进入老年代,防止在to区和from区之间发生大量的内存复制.

长期存活的对象(年龄默认为15)将进入老年代.

JVM对象创建与内存分配机制

虚拟机遇到一条new指令是,首先会去检查这个指令的参数在常量池中能否定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载,解析和初始化,如果没有,则必须先执行响应的类加载,
类加载通过后,接下来分配内存,若Java堆中内存是绝对规整的,则使用指针碰撞方式分配内存,如果是不规整的,就使用空闲列表方式分配,
在分配内存时还需要考虑并发问题,可能同一个位置,涉及到多个对象的争抢,有两种处理方式,一种为CAS同步处理,谁抢到就是谁的,没抢到重试,一种为TLAB本地线程分配缓冲,即每个线程在java堆中独立预先分配一块内存.

然后是内存空间的初始化操作,接着做一些必要的对象设置(对象头),最后执行init方法

对象的创建

java中有以下几种对象创建的方式:

  • 使用new关键字调用无参或带参数构造函数,
  • 调用class的newInstance调用无参构造函数
  • 使用Constructor类的newInstance方法调用有参数的和私有的构造函数
  • 使用clone方法,没有调用构造函数,需要实现cloneable接口并实现定义clone方法
  • 使用反序列化,没有调用构造函数,需要类实现Serializable接口
1
2
3
4
5
6
7
8
9
10
11
12
Employee emp1 = new Employee();

Employee emp2 = Employee.class.newInstance();

Constructor<Employee> constructor = Employee.class.getConstructor();
Employee emp3 = constructor.newInstance();

Employee emp4 = (Employee) emp3.clone();

ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.obj"));
Employee emp5 = (Employee) in.readObject();

为对象分配内存

内存分配根据java堆是否规整,有两种方式

  • 指针碰撞:如果java堆的内存是规整的,则所有用过的内存放在一边,空闲的放在零一边,分配内存时,将位于中间的指针计时器向空闲的内存移动一段与对象内存大小一致的距离,这样就完成了内存的分配.
  • 空闲列表:java虚拟机维护了一个列表来记录哪些内存是可用的,在分配时,就从列表中查询可用的内存分配给该对象,同时在分配后更新记录列表

内存分配的并发处理

对象的创建是一个频繁的过程,可能会出现同一块内存地址多个对象创建争抢的问题,有两种解决方案

  • CAS+失败重试,对分配内存空间的动作进行同步处理.
  • TLAB,本地线程分配缓冲,把内存的分配动作按照线程划分在不同的空间中进行,每个java线程都预先分配一小块内存,哪个线程要分配内存,就在哪个线程对应的内存空间进行分配.只有在TLAB用完并分配新的TLAB时,才需要同步锁.

对象在内存中的存储

对象在内存中的存储主要分为三部分区域,对象头,实例数据和对其填充.
对象头包括两部分信息,一部分用于存储对象自身运行的数据,比如哈希吗,GC分代年龄,锁标志状态,线程持有锁等,另外一部分是类型指针,也就是对象指向它的类元数据的指针.

对象在栈的分配

JVM通过逃逸分析,确定该对象不会被外部访问时,可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随着出栈而销毁.

对象逃逸分析:就是分析对象动态的作用域,当一个对象在方法中被定义后,它可能会被外部所引用,比如作为调用参数传递到其他地方,又或者确定它不会被外部所引用,该方法结束时,这个对象就是无效的,这类的独享就可以分配在栈内存中.

对象在eden区分配

大多数情况下,对象在新生代的eden区分配,当eden区没有足够空间分配时,触发minor gc.

minor gc:又叫young gc,指的是发生在新生代的垃圾收集动作,回收速度一般比较快.

major gc:或者叫full gc,一般会回收老年代,年轻代,方法区的垃圾.

大对象数据直接进入老年代

大对象数据就是需要大量连续内存空间的对象,如字符串,数组,大对象数据直接进入老年代,就可以避免为大对象分配内存时的内存复制操作.

长期存活的对象直接进入老年代

默认在新生代中的对象分代年龄如果达到15,则会进入老年代,CMS收集器默认为6

动态年龄判断

当前放对象的survivor区,一批对象的总大小大于这块区域的内存大小时,则所有大于等于这批对象年龄最大值的对象都进入老年代.如年龄1+年龄2+…+年龄N的对象存储总和大于该区域的一般了,则该区域中所有大于等于N的对象进入老年代.
这个规则是希望那些可以长期存活的对象尽早的进入老年代,对象动态年龄判断机制一般是在minor gc之后触发的

老年代空间分配担保机制

年轻代每次在minor gc之前都会计算老年代剩余可用空间,如果可用空间小于年轻代里所有对象大小之和(包括垃圾对象),则会触发一次full gc,对老年代和年轻代一起回收一次垃圾,收集完后如果还是没有足够的空间存放新的对象,则会发生oom.
如果minor gc之后剩余存活的需要挪动到老年代的对象的大小还是大于老年代可用空间,也会触发full gc.
full gc完成之后,如果还是没有足够的空间,也会发生oom.

对象内存回收

垃圾回收的第一步就是判断哪些对象已经死亡,没有被任何途径使用的对象,有两种方式

  • 引用计数器:没当有一个地方引用它,计数器就加1,当引用失效,就减1,计数器为0时,则就是不可能再被使用的.但是它存在一个问题就是对象之间的相互循环引用问题,比如两个对象A和B相互引用着对象,除此之外再没有别的引用,按理应该都被回收,但是计数器都不为0.

  • 可达性分析算法:将gc roots对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象.GC ROOTS根节点包括线程栈的本地变量,静态变量,本地方法栈的变量等.

常见的引用类型

  • 强引用,发生gc时不会被回收,比如 User user = new User()
  • 软引用:有用但不是必须得对象,在发生内存溢出之前会被回收,将对象用SoftReference软引用的对象包括,正常情况下不会被回收,但是GC做完发现释放不出空间存放新的对象时,就会把这些软引用对象回收.如public static SoftReference<User> user = new SoftReference<User>(new User());
  • 弱引用:将对象用WeakReference引用类型包裹,GC时会直接回收掉.如public static WeakReference<User> user = new WeakReference<User>(new User());
  • 虚引用:无法通过引用获得对象,几乎不用

判断对象是否存活

即使在可达性分析中的不可达对象,也并非是非死不可的,只是处于缓刑阶段,真正宣告一个对象的死亡,至少要经历再次标记的过程,标记的前提是对象的不可达.

第一次标记并进行一次筛选:
筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,对象将直接回收

第二次标记:
如果对象覆盖了finalize()方法,并且对象在finalize方法中拯救了自己,只要重新与引用链上的任何一个对象建立关联接口,比如把自己赋值给某个类变量或对象成员变量,那在第二次标记时,它将移除回收集合.

如何判断一个类是无用的类

方法区主要回收的是无用的类,需同时满足以下条件

  • 该类所有的对象实例已经被回收,也就是java堆中不存在该类的任何实例
  • 加载该类的classloader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
作者

Jonathan

发布于

2020-07-02

更新于

2020-12-05

许可协议