> 文章列表 > JVM常见问题解析

JVM常见问题解析

JVM常见问题解析

日升时奋斗,日落时自省 

目录

1、JVM内存区域划分

2、JVM类加载

2.1、类加载流程

2.2、类加载情况

2.3、双亲委派模型

2.3.1、JVM默认提供了三个类加载器

2.3.2、类加载器工作过程

3、垃圾回收机制 GC

3.1、GC的优劣

3.2、GC工作过程

3.2.1、判定垃圾

3.2.2、清理垃圾

3.2.3、分带回收

JVM全称Java  Virtual Machine,意为Java虚拟机

虚拟机是指通过软件模拟的具有完整硬件功能的,运行在一个完全隔离的环境中的完整计算系统。

常见的虚拟机:JVM,VMwave,Virtual Box

JVM和其他两个虚拟机的区别:
<1>VMwave 和 Virtual Box 是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器

<2>JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪

注:JVM是一台被制定过,但是现实中不存在的计算机

JVM也在不断发展演变,HotSpot VM 最主流使用的JVM (JVM在JDK里面包含着),也就是说JDK使用广泛的并且带有的这个JVM就是比较盛行的,Oracle官方JDK 和开源OpenJDK这两者占据里较大市场范围,所以HotSpot VM应用也就比较广泛

JVM此处设计到的常见问题分为以下三点:

<1>JVM内存区域划分

<2>JVM类加载机制

<3>JVM垃圾回收机制

1、JVM内存区域划分

JVM也就是启动的时候,会申请到一整个很大的内存区域(JVM是一个应用程序,要从操作系统这里申请内存),JVM就根据需要,把整个空间,分成几个部分,每个部分各自有不同的功能作用

区域划分有被分五个块

(1)Native Method 1Stacks(本地方法栈线程私有

native 就表示是 JVM 内部的 C++ 代码 该区域就是给调用native方法(JVM内部方法)准备的栈空间

(2)Program Counter Register(程序计数器线程私有

 记录当前程序执行到哪个指令(很小的一块内存一个地址)也是每个线程有一份

(3)JVM Stacks(虚拟机栈线程私有

给Java代码使用的 ,此处所谈到的栈,是JVM中的特定空间

<1>对于虚拟机栈,这里存储的是方法之间的调用关系

整个栈空间内部,可以认为是包含很多个元素(每个元素表示一个方法)把这里的每个元素,称为是一个“栈帧”,这一个栈帧里,会包含这个方法的入口地址,方法的参数,返回地址,局部变量等

注:栈上的内存空间是跟着方法走的,调用一个方法,就会创建栈帧,方法执行结束了,这个栈帧就销毁了 ,其中栈空间有上限,JVM启动的时候是可以设置参数,其中有一个参数就可以设置栈空间的大小

<2>对于本地方法栈,存储的是native方法之间的调用关系

注:此处栈和数据结构的哪个栈不是同一个,分开理解;数据结构的栈是一个通用的,更广泛的概念,JVM的栈特指一块内存空间

针对线程:

<1>创建一个线程,能做很多事情,在虚拟机栈,这里搞一份新的栈,是其中的一个工作,最核心的工作,是去系统内核里面,让内核给创建一个线程

<2>线程创建会占用栈空间(也就分配给他的空间),栈空间整体一般不会很大,但是每个栈帧其实占的尺寸也比较小,但不会轻易沾满,常见到能把栈空间占满的也就是见过无限递归,会遇到栈溢出的情况(也就是说正常创建线程占用栈空间一般都够用)

<3>栈同时也将线程隔离了起来,不会因为一个线程栈溢出而影响其他线程

(4)Heap(堆区线程共享

堆:是整个JVM空间最大的区域,new出来的对象,都是在堆上的,类的成员变量,也是在堆上

堆区:是一个进程只有一份的

栈:每个线程有一份,一个进程有N个

每个JVM就是一个java进程(几个进程就是几个JVM)

这里说一个线程拥有自己的栈,也不完全说是私有的(一个线程栈上的内容,可以被另一个线程使用到,捕获变量就可以做到从main线程中捕获)

试问:多个Java进程为啥不共用一个JVM???

多个Java程序,也是需要关注隔离性的,不要相互影响,进程自身都把隔离性做好了,咱们直接用这个现成的就行,现成的咋来的??

操作系统运行一个进程,会搞一个地址空间,会把进程依赖的动态库啥的都往这个空间里放一份(保证效率),每创建一个进程,系统都给这个进程搭了一堆台子,尽量保证进程之间是相互隔离的,不受影响;

JVM再实现一个JVM承载多个java进程还再实现一遍隔离性,前人已经实现了,现在就不需要了

C、C++、Go、Rust 都是把代码编译成native code 也就是cpu 能识别的机器指令,针对不同系统/cpu生成的机器指令是不一样的(编译出来的可以执行程序也是不一样的)Java,Python,PHP,为了跨平台,都是统一翻译成指定的字节码,,然后有对应的虚拟机转换成机器指令

(5)Metaspace(元数据区线程共享

我们常听见的方法区也就是元数据区,原来叫做方法区,java 8开始后叫做元数据区

包含以下三个部分:

<1>klass(类元信息):类对象

<2>常量池

<3>静态成员

元数据区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的

一个进程里,只有一块多个线程公用这一块

涉及问题:某个变量在哪个区域上???

<1>局部变量 在 栈

<2>普通成员变量 在 堆

<3>静态成员变量 在方法区 / 元数据区

基于JDK8元空间的变化:

<1>对于HotSpot来说,JDK8元空间的内存,这样元空间的大小就不在受JVM最大内存的参数影响了,而是与本地内存的大小有关

<2>JDK8 中将字符串常量池移动到了堆中

运行时常量池:

运行时常量池是方法区的一部分,存放字面量与符号引用

字面量:字符串(JDK8移动到堆中)、final常量、基本数据类型的值

符号引用:类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符(后面解释符号引用)

2、JVM类加载

类加载过程就是.class文件,从文件(硬盘)被加载到内存中(元数据区)这样的过程

大体过程是以上这样,下面咱开始细说

2.1、类加载流程

.class文件是.java文件通过javac编译来的

类加载过程分为这五部分:加载->验证->准备->解析->初始化 (这些步骤的根源来自JVM C++代码)整个过程也就是类的生命周期

 <1>加载:把.class文件找到,打开文件,读文件,把文件内容读到内存中,最终加载完成,是要得到类对象

<2>验证:检查下.class文件格式对不对,.class是一个二进制文件,这里的格式是有严格的说明的

在官方文档下可以看,.class文件的具体格式有个啥

验证选项:文件格式验证,字节码验证,符号引用验证等

 magic具体指的什么?

表示不同的后缀,.exe 就是其中一种(指的就是这样的文件分类)

<3>准备:给类对象分配内存空间(先在元数据区占个位置),也会使静态成员被设置成0值

<4>解析:初始化字符串常量,把符号引用转为直接引用

字符串常量,得有一块内存空间,存这个字符的实际内容,需要一个引用,来保存这个内存空间的起始地址

在类加载之前,字符串常量,此时是处在.class文件中的,此时这个“引用”记录的并非是字符串常量的真正的地址,而是它在文件中的“偏移量”这个东西(符号引用)

符号引用:类比一下,以排队为例,排队去看电影,作为是有编号的,但是你并不知道你坐在哪里,但是你能知道你左边和右边坐的是谁,此时像是一个虚拟地址,

类加载之后,才真正把这个字符串常量给放到内存中,此时才有“内存地址”,这个引用才能被真正赋值成指定内存地址(直接引用)

直接引用:到了电影院之后,让当家按顺序坐下,此时就有了对应的位置,也就真的内存地址

<5>初始化:调用构造方法,进行成员初始化,执行代码块,静态代码块,加载父类

2.2、类加载情况

不是java程序一运行,就把所有的类都加载了,而是真正用到才加载(懒汉模式)

<1>构造类的实例

<2>调用这个类的静态方法 / 使用静态属性

<3>加载子类 就会先加载其父类

用到了,才加载,一旦加载过之后,后续的话再使用就不必重复加载了

2.3、双亲委派模型

加载:把.class文件找到,读取文件内容

双亲委派模型,描述的是这个加载找.class文件基本过程

2.3.1、JVM默认提供了三个类加载器

<1>BootstrapClassLoader 负责加载标准库中的类

注:java规范,要求提供过那些类,无论是哪种JVM的实现,都会提供这些一样的类

<2>ExtensionClassLoader 负责加载JVM扩展库中的类

注:规范之外,由实现JVM的厂商/组织,提供的额外的功能

<3>ApplicationClassLoader 负责加载用户提供的第三方库/用户项目代码中的类

 他们之间存在的关系:

 上述三个类,存在“父子关系”,但是不是java中父类继承关系,而是相当于每个ClassLoader 有一个parent属性,指向了自己的父亲的类加载器

2.3.2、类加载器工作过程

加载一个类的时候,会先从ApplicationClassLoader开始,但是这里不会立即处理,而是会把加载任务委托给父亲,让自己的父亲去进行;

此时ExtensionClassLoader接收到加载任务,但是也不是真的加载,因为他也有一个父亲,就把该加载认为委托给自己的父亲;

BootstrapClassLoader接收到加载任务,他也想委托给自己的父亲,但是类似于parent属性为null,那就只能自己进行加载;

此时BootstrapClassLoader就会搜索自己负责的标准库目录的相关的类,如果找到,就加载;如果没有找到,那继续由子类进行加载进行加载

ExtensionClassLoader 开始正式搜索JVM扩展库相关的目录,如果找到就加载,如果没有找到,就由子类加载器进行加载

ApplicationClassLoader 开始正式搜索用户项目相关的目录如果没有找加载,没找到,由子类加载器进行加载(但是当前下面已经没有子类了,说明完全找不到,只能抛出异常)

以上难免会有一个问题,那直接BootstrapClassLoader从这里开始加载不是更好嘛;

但是程序中凡事也要遵循一个章程,上述套用顺序其实是出自于JVM实现代码的逻辑

这段代码大概是类似于“递归”的方式写的,其实从最上面这里直接开始,就导致了从下到上,又从上到下的过程

这个顺序,主要目的就是为了保证BootstrapClassLoader能够先加载,Application能够后加载,,这就可以避免说因为用户创建了一些奇怪的类,引起不必要的bug(为啥这么说嘞)

再另一方面,类加载器,其实是可以用户自己定义的,上述三个类加载器是JVM自带的,用户自定义的类加载器,也可以加入到上述流程中,就可以和现有的加载配合使用

3、垃圾回收机制 GC

垃圾回收:什么是垃圾,才会被回收处理??垃圾回收,就是把不用的内存帮我们自动释放了

内存泄漏是一个很严重的问题,空间疯狂占用,不被回收,空间被占用完结的时候,剩下要存入的数据也就会随之而丢失,因为后续的内存申请无法操作

看看不同语言的内存回收处理

C语言中有malloc、C++ 有new 这两者内存空间需要手动方式进行释放,malloc释放内存free,new内存释放delete

针对C\\C++:如果不手动释放,这块内存的空间就会持续存在,一直存在到进程结束,(堆上的内存生命周期比较长,而栈的话空间会随着的方法执行结束,栈帧销毁而自动释放,堆则默认不能自动释放)

GC是其中最主流的一种方式,很多语言都采用了GC来解决内存回收问题,例如Java、Python、PHP、JS 等

3.1、GC的优劣

当然一个事务的创建都是有好有坏的

GC优势:非常方便,降低代码bug出现概率,内存泄漏在C/C++中也是很常见的问题

GC缺陷:需要消耗额外的系统资源,也有额外的性能开销(C/C++追求效率所以不用)

GC除以上缺陷还有一个比较关键的问题:STW问题(stop the world)

如果有时候,内存中的垃圾已经很多了,此时触发一次GC操作,开销可能非常大,大到可能就把系统资源吃了很多;其中会GC回收垃圾的时候可能会涉及到一些锁操作,导致业务代码无法正常运行,这样的卡顿,极端情况下,可能会出现几十毫秒甚至上百毫秒

GC优化将会带来更大益处,所以当前GC也在更新迭代,新版java(13)开始引入zgc垃圾回收器,设计更加精致,可以使STW能控制在1毫秒以下

前面提到了JVM的区域划分是大块,GC也只是在其中一个为区域其主要作用,就是堆区,刚刚说到C/C++的时候,就说了堆是不能自己释放的

GC是以“对象”为基本单位,进行回收的(而不是字节)

那来分一分情况:

3.2、GC工作过程

3.2.1、判定垃圾

这个步骤很重要,哪个对象是垃圾,哪个对象不是,判定为垃圾表示的该对象在当前程序中不会再使用了;就会用另一种情况,哪个对象可能后面还会使用;

关键问题:该对象还没有“引用”与他相关

针对 java中 : 使用对象,只有这一条路,通过引用来使用;如果一个对象,有引用指向它,就可以能被使用到,如果一个对象,没有引用指向了,就不会再被使用了

针对“引用指向”判定有以下两种方法

(1)引用计数(不是java使用的,Python。PHP使用的)

每个对象被分配了一个计数器,每次创建一个引用指向该对象,计数器就会+1,每次引用被销毁了计数器-1  (拿代码来看一下)

 这个办法简单有效,但是java没有使用,因为里面也不足之处(下面解释)

<1>内存空间浪费的多(利用率低)

每个对象都要分配一个计数器,如果按4个字节算的话,代码中的对象非常少,无所谓,如果对象特别多了,占用额外空间就会很多,尤其是每个对象都比较小的情况

一个对象体积1k ,此时多4字节,没事,毕竟在一个对象中占不了多少

一个对象体积是4字节, 此时多几个自己,扩大了几倍

<2>循环引用的问题

看一下存在的循环问题:

Python 和 PHP能使用说明有别的机制来解决,来避免循环引用

 (2)可达性分析(针对java)

java中的对象,都是通过引用来指向并访问的,经常是一个引用指向一个对象,这个对象里的成员,又指向别的对象

整个java中所有的对象,通过这种链式/树形结构,整体给串起来

可达性分析,就是把所有这些对象被组织的结构视为可是树,就从树根节点出发,遍历树所能被访问到的对象,标记成“可达”(不能被遍历到的就为“不可达”)

 JVM自己捏着一个所有对象的名单,通过上述遍历,把可达的标记出来了,剩下的不可达的就不可以作为垃圾进行回收了,每次new的一个对象,JVM都会记录下来,JVM会自动一共有哪些对象,每个对象的地址

可达性分析需要进行类似于“树遍历”这个操作相比于引用计数来说肯定要更慢一些的,对于树的遍历本身就是慢,但是上述可达性分析遍历操作,并不需要一直进行,只需要一段时间分析一遍就可以了

那当前能不能解决刚刚的引用循环问题呢??

答案:可以,给已经经过的对象标记成可达

 可达性分析遍历的起点,称为GCroots

3.2.2、清理垃圾

有三种基本做法:

(1)标记清除

 这里紫色的就算是被标记要删除的,但是删除之后会出现内存碎片化问题,被释放的空闲空间也是零散的,不连续的,申请内存要求的是连续的空间,总的空闲空间可能很大,但是每一个具体的空间都很小,可能导致申请大一点内存就失败了(展示例)

例如:你现在有5k空间 ,但是空间被分成1k空间共5个,此时如果申请2k内存,就会申请失败了,因为没有连续的空间了,每个分下来的空间都是有空隙的

(2)复制算法

解决内存碎片化问题

这个地方,直接把整个内存分为两半,用一半,丢一半(理解:一半用来存有用的,另一半用来垃圾回收)

 复制算法,就是把“不是垃圾”的对象复制到另一半,当前这一半就把整个空间删除掉

每次触发复制算法,都需要将另外一半内存中的数据拷贝过去,虽然解决了内存碎片化问题,但是仍然还有很大的缺陷

缺点:

<1>空间利用率低

<2>如果要是垃圾少,有效对象很多,复制成本较大(有效对象少,那就快)

(3)标记整理

解决复制算法的缺点

类似于顺序表删除中间的元素,会有元素搬运的操作(图解一下)

 <1>保证了空间利用率,同时也解决了内存碎片化问题

<2>但是看的出来其实该算法效率也不高,如果搬用的空间比较大,开销也不小

3.2.3、分带回收

这里单独拿出来是为了与清理垃圾作对比,这个算法很完美(复合了前面所提及策略)

垃圾回收也会适应不同场景,不同场景适应不同的算法

对于java的对象也存在一定的规律(规律也是有一系列实验和论证过程的)

Java的对象要么就是生命周期特别短的,要么就是特别长,根据生命周期的长短,分别使用不同的算法,给对象引入一个概念,年龄(单位不是年,而是熬过GC的轮次),年龄越大对象存在时间越长久(图片解析)

熬过怎么理解??

经过一轮可达性分析的遍历,发现这个对象还不是垃圾,就算熬过一轮

<1>伊甸区 :存放的是刚刚new对象,现在还没有经历可达性分析,属于新生代,新生代内存同时还划分了,不同空间,针对每次可达性分析进行淘汰,所以伊甸区,存放的还是没有经历一次可达性分析的对象

<2>幸存区:经过一轮的可达性分析遍历,还没有被回收的对象

从伊甸区  =》幸存区 (从伊甸区到幸存区)

能看到伊甸区设定的内存更大,相比之下幸存区内存空间很小(试问:幸存区这么小的空间够用吗?)

因为java声明周期大部分都是比较短,很多对象在第一次可达性分析的时候就已经被做为垃及回收了

两个幸存区和复制算法是一样的,但是幸存区的空间不大,也能接收浪费的这种情况,两个幸存区空间进行相互拷贝

<3>老年代:如果这个对象已经再两个幸存区中来回拷贝很多次,就算是经受起考核进入老年代

注:老年代都是年纪大的对象,生命周期普遍更长,针对老年代,也会进行周期GC扫描,但是频率更低了(减少了遍历的开销)