JVM虚拟机一遍过【超详细】

一、前言

Java是一门面向对象的编程语言,其中面向对象的三大特点:封装、继承、多态,增加代码的复用率和灵活度,Java最显著的两个特征:一方面是跨平台能力、另一方面就是垃圾收集器。

JAVA跨平台

所谓的"书写一次,到处运行",能够非常容易地获得跨平台能力。
" 一次编译、到处运行"指Java语言跨平台的特性,Java的 跨平台特性与Java虚拟机(JVM)的存在密不可分,可在不同的环境中运行。
例Windows平台和Linux平台 都有相应的JDK,安装好JDK后也就有了Java语言的运行环境,在不同的平台都有可以让Java语言运行的环境,所以才有了Java一次编译,到处运行这样的效果。

JDK、JRE与JVM

JDK(Java Development Kit )

JDK是Java开发工具包,其中包括一些编译工具(javac.exe)和JRE(java的运行环境、java基础类库)

JRE(Java Runtime Environment)

Java运行环境,包含Jvm虚拟机和Java核心类库,单独有JVM不能执行class,因为在解释class的时候JVM需要调用解释所需要的类库lib(jre里有运行.class的java.exe)

JVM(Java Virtual Machine)

JVM是Java运行时的环境, JVM在执行字节码时,把字节码解释成具体平台上的机器指令执行。
它能够屏蔽不同操作系统底层的差异,不同的操作系统上都针对的有不同的JVM实现。
字节码文件并不直接由机器的操作系统读取,而是经过虚拟机间接与操作系统交互,由虚拟机将程序解释给本地系统执行。

三者之间的关系如下所示:
在这里插入图片描述

JVM存在意义

JVM能够让Java项目跨平台实现,编译一次,到处运行,灵活便捷
另外,Java中还有一显著特点,就是垃圾收集器,能够自动分配内存以及自动内存回收,开发人员就可以只关注业务,不用关注内存的操作,自动内存分配回收机制大大的提高了项目的开发效率。

问题一:既然JVM帮助我们管理好了内存,那我们还有必要学习了解JVM吗?

必须学!!如果程序在执行阶段,发生了内存泄漏,内存溢出,以及由于对象回收导致程序卡顿太久等问题,此时,是需要我们去排查问题,要想从本质上解决问题,就必须要了解JVM的运作机制、内存模型等!!!

知道了JVM的重要性,话不多说,接下来,就正式开始JVM的深入学习咯…!!!!!!

二、深入JVM

1. JVM 内存模型

Jvm内存结构由9块组成:

程序计数器(pc寄存器)、虚拟机栈、本地方法栈、堆、方法区、直接内存、类加载器、执行引擎、垃圾回收器
其中线程共享:堆、方法区
线程私有程序计数器(pc寄存器)、虚拟机栈、本地方法栈

JVM内存模型整体关系图如下所示:
图一:
在这里插入图片描述

图二:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
请添加图片描述

2. JVM 运行数据区

顾名思义:运行数据区就是Java程序在运行的时候,一些信息存放的位置区域
运行数据区包括了程序计数器(pc寄存器)、虚拟机栈、本地方法栈、堆、方法区这些区域

接下来就来一 一解析JVM运行数据区的各个区域…

2.1. 程序计数器

程序计数器是一块较小的内存空间,是用来保存字节码指令执行的地址(当前线程所执行的行号指示器),native修饰的方法保存的地址为null
.
JVM的字节码解释工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,跳转、异常处理、恢复线程等功能操作都是依赖这个计数器完成

问题一:程序计数器属于线程私有,原因是什么?为啥一定是线程私有?线程共享的不行吗?

程序计数器必须是线程私有的,原因在于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的(OS中采用的是时间片轮转调度算法进行线程调度),一个处理器(CPU)只能执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都必有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储
.
由于程序计数器是线程私有的,所以其生命周期和线程的生命周期一样,并且也是JVM内存模型中唯一一个不会出现OOM(内存溢出)的区域

2.2. 虚拟机栈

Java虚拟机是线程私有的,它的生命周期和线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

虚拟机栈中基本的存储单位是栈帧,每个方法的调用时都是在虚拟机栈中创建一个栈帧,栈帧的结构如下图所示:

在这里插入图片描述

接下来解释一下栈中的结构

结构 说明
局部变量 用来存储临时的基本类型数据、对象引用地址、以及return后要执行的字节码指令地址
操作数栈 程序运行时的操作数据运算的区域,之后把运算完的数据放到局部变量中。操作数栈底层是数组实现,但是不能通过索引访问元素、只能进行入栈、出栈两个操作
动态链接 在函数调用的时候,将符号引用转为直接引用,直接指向指针地址
方法返回地址 方法正常退出或异常退出的定义,有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常(未处理)。不管哪一种都会导致当前栈帧的弹出。

虚拟机栈是不存在垃圾回收问题,但是会存OOM(内存溢出)问题

java虚拟机规范允许Java栈的大小是动态或者固定不变的
1,如果采用固定大小,那么一个线程的java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈大小超过虚拟机栈允许的最大容量,那么Java虚拟机会抛出StackOverflowError(SOF)栈内存溢出异常
.
2,如果java虚拟机栈可以动态扩展,当在尝试扩展但无法申请到足够的内存时,或者在创建新的线程动态扩展时没有足够的内存去创建对应的虚拟机栈,这时Java虚拟机就会抛出OutOfMemoryError(OOM)内存溢出异常
这两种异常都是属于内存溢出异常

2.3. 本地方法栈

Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用,本地方式是用native修饰,底层C\C++编写*
本地方法栈,也是线程私有的

当某个线程调用一个本地方法时,它不受限于JVM虚拟机:

➢本地方法可以通过本地方法接来访问虚拟机内部的运行时数据区
.
➢它甚至可以直接使用本地处理器中的寄存器
.
直接从本地内存的堆中分配任意数量的内存

并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈, 在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一

2.3. 堆内存

堆内存是Java虚拟机所管理的内存中最大的、并且是被所有线程共享的一块区域
.
在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。
.
Java堆是垃圾收集器管理的主要区域,因此也被成为"GC堆"
.
从内存回收角度可分为:新生代和老生代
.
Java堆可以处于物理上不连续的内存空间中。当前主流的虚拟机都是可扩展的(通过 -Xmx 和 -Xms控制)。如果堆中没有内存可以完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

2.4. 方法区

方法区是所有线程共享的内存区域
它用于存储已被Java虚拟机加载的类信息、运行时常量池、静态变量、及时编译(JIT)编译后的代码等数据。
当方法区无法满足内存分配需求时,抛出OutOfMemoryError(OOM)异常。

方法区抛OOM的场景如下:

加载了大量第三方jar包
Tomcat部署过多工程30-50个
大量动态生成的反射类(检查代码中是否存在大量反射操作)

方法区与永久代、元空间的关系

方法区是一种规范
永久代指物理上的堆内存的一块空间,这块实际的空间完成了方法区存储字节码、静态变量、常量的功能
JDK1.7及之前,方法的区是具体实现叫永久代,永久代使用的是JVM内存存储,而JDK1.8之后,方法区移到本地,元空间使用的本地内存存储

问题一:元空间为啥代替永久代?

1,永久代内存经常不够用或者发生内存泄露。
.
2,类及方法的信息等比较难确定其大小,对于永久代的大小不好设置,设置小了会频繁发生GC,而且永久代的GC是效率很低且费时间,因为判断一个类是否可以被回收的条件很苛刻且费时,会占用资源,影响用户线程的执行。
由于永久代不是本地内存是虚拟机内存也就是是属于JVM进程的内存,所以如果设置过大就回造成内存的浪费,空余部分内存JVM进程本身用不到也不让其他进程使用;
而如果使用元空间的话直接使用的是本地内存(直接内存),默认无最大值限制,这样可以减少JVM的GC操作,因为GC会降低程序性能,如果设置最大值由于使用的是直接内存,空余的内存也是允许其他进程使用的

.
3,永久代进行GC复杂度高、且回收效率偏低。

3. 直接内存

直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域
直接内存是本地内存中开辟的一段空间
在JDK1.4之后调用native 函数库直接分配堆外内存,然后通过DirectByteBuffer对象作为这块内存的引用进行操作。这样避免了在Java堆和 Native(本地)堆中来回复制数据。
可以使用Java 中的Unsafe 类,做了一些本地内存的操作

直接内存与堆内存的区别:

直接内存申请空间耗费很高的性能,堆内存申请空间耗费比较低
直接内存的IO读写的性能要优于堆内存,在多次读写操作的情况相差非常明显
这个Java类java.nio.ByteBuffer可以分配直接内存以及进行内存的直接读写,有兴趣可以了解其API

4. 执行引擎

执行引擎是JVM核心组件,是负责将字节码指令转换成机器指令,供操作系统识别执行。
执行引擎包含JIT即时编译、垃圾回收器

执行引擎中,字节码指令转换成机器码指令的给过程有两种方式:解释器、JIT即时编译

解释器

当JVM启动时,对字节码采用逐行解释的方法执行,将每条字节码指令编译成对应的本地机器指令并执行

JIT编译器(即时编译器)

将字节码指令直接编译成本地机器指令,只负责热点代码的编译工作

关系如下图所示
在这里插入图片描述

JIT即时编译

JIT英文Just In Time,通过JIT技术,能够做到Java程序执行速度的加速
JIT目的是避免方法被解释执行,将整个方法编译成机器码,然后缓存机器码,每次执行该方法时,只执行缓存中的机器码,这种方式可以使执行效率大幅度提升
为了提高程序执行速度,引入JIT 技术。当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”。然后JIT会把部分“热点代码”编译成本地机器相关的机器码,并进行优化,然后再把编译后的机器码缓存起来,以备下次使用。

问题一:JIT为啥能够加速程序的执行?

Java是一门解释型语言(或者说是半编译,半解释型语言)。
Java通过编译器javac先将源程序编译成字节码文件,再由JVM解释执行字节码文件。
对字节码的解释执行过程实质为:JVM先将字节码翻译为对应的机器指令,然后执行机器指令。很显然,这样经过解释执行,其执行速度必然不如直接执行二进制字节码文件。

HotSpot VM中JIT的分类如何?

JDK1.8中,HotSpot JVM中内嵌两个JIT编译器,分别为Client Compiler(C1编译器)和Server Compiler(C2编译器),
开发者可以通过命令来设置JVM运行时使用哪种JIT,但64位机器只能使用Server Compiler

实际中,JVM中的JIT用的是C2


client 指定使用C1编译器
C1编译器会对字节码进行简单和可靠的优化,耗时短
以达到更快的编译速度

server 指定使用C2编译器
C2编译器会进行更长时间的优化,以及采取更激进的优化策略,耗时长
使得编译后的代码执行效率更高

C2编译器的优化策略:

1,标量替换

用标量替代聚合对象的属性值

标量(scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

2,栈上分配

对于未逃逸的对象不在堆中而在栈上分配(通过标量替换 而不是创建对象)

当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。
为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。
那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

3,同步消除

清除无效同步操作,synchronized块和关键字(锁消除)

通过逃逸分析,可以确定一个对象是否会被其他线程进行访问
如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行,前提没有出现线程逃逸的情况。那该对象的读写就不会存在资源的竞争,不存在资源的竞争,则可以消除对该对象的同步锁。

.

怎么去判断是热点代码?

判断热点代码需要热点探测功能,HotSpot VM所采用的的热点探测方式是基于计数器的热点探测

1,热点探测

HotSpot VM为每一个方法建议两个不同类型的计数器,来实现热点探测,分别是方法调用计数器和回边计数器
方法调用计数器:统计方法的调用次数
回边计数器:统计循环体的循环次数

5. 类加载器

类加载器是用于加载类的对象

类加载过程

类加载得过程就是将字节码文件加载到JVM内存中,其中加载过程步骤分为:加载、验证、准备、解析、初始化

如下图所示:
在这里插入图片描述

加载

类加载过程的第一步,主要完成下面这三件事情:
①. 通过全类名获取定义此类的二进制字节流(将类的class文件读入到内存
②.将字节流所代表的静态存储结构(静态数据结构转换为方法区运行时数据结构(运行时数据结构
③.在内存(堆)中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口,该Class对象封装了类在方法区中的数据结构,并且向用户提供了访问方法区数据结构的接口,即Java反射的接口

验证

主要验证是否符合字节码文件格式规范、原数据描述是否符合java语言规范要求、字节码语义是否合法、符号引用验证(确保后续解析正常),确保加载的类信息符合JVM规范,没有安全方面的问题

如下图所示:
在这里插入图片描述

准备

静态变量分配内存并设置默认值,这些内存都将在方法区中进行分配

解析

虚拟机常量池的符号引用替换为直接引用过程

初始化

初始化是为类的静态变量赋初始值
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
虚拟机会保证一个类的<clinit>() 方法在多线程环境中被正确加锁和同步
< clinit> () 方法是编译之后自动生成的,对于< clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 < clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个进程阻塞,并且这种阻塞很难被发现。

类初始化触发得条件

通过构造方法 new 一个对象时
调用静态方法时
访问静态属性(注意不是静态常量)
静态属性赋值
访问静态代码块

类加载器分类

启动(Bootstrap)类加载器
扩展(Extension)类加载器
系统(应用)(Application)类加载器

自定义类加载器

如下图所示:
在这里插入图片描述

1,启动(Bootstrap)类加载器

负责加载 JAVA_HOME\lib 目录中的类
因为BootstrapClassLoader并不继承自 java.lang.ClassLoader,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作, JAVA_HOME\lib\rt.jar 里的类都是原生类,由C++实现

2,扩展(Extension)类加载器

负责加载 JAVA_HOME\lib\ext 目录中的类
扩展(Extension)类加载器由Java实现

3, 系统(应用)(Application)类加载器

负责加载用户路径(classpath)上的类库

双亲委派机制

JVM把加载类的请求交给父类处理,有父类尝试加载,加载不成功再有本类加载器处理,这种模式成为双亲委派模式
.
当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。如果加载失败,抛出类加载异常

如下图所示:
在这里插入图片描述

双亲委派机制流程

JVM在加载⼀个类时,会调⽤AppClassLoader的loadClass⽅法来加载这个类,不过在这个⽅法中,会先使⽤ExtClassLoader的loadClass⽅法来加载类,同样ExtClassLoader的loadClass⽅法中会先使⽤BootstrapClassLoader来加载类,如果BootstrapClassLoader加载到了就直接成功,如果BootstrapClassLoader没有加载到,那么ExtClassLoader就会⾃⼰尝试加载该类,如果没有加载到,那么则会由AppClassLoader来加载这个类。

双亲委派意义

1,可以避免类的重复加载,当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类。
2,防止类被随意篡改, 保证了安全性。因为Bootstrap ClassLoader在加载的时候,只会加载JAVA_HOME中的jar包里面的类,如java.lang.Integer,那么这个类是不会被随意替换的,除非有人跑到你的机器上, 破坏你的JDK

类加载器间的关系

启动类加载器,由C++实现,没有父类。
拓展类加载器(ExtClassLoader),由Java语言实现,父类加载器为null
系统类加载器(AppClassLoader),由Java语言实现,父类加载器为ExtClassLoader
自定义类加载器,父类加载器肯定为AppClassLoader。

自定义类加载器

将用户指定的字节码文件加载到内存中,而不走双亲委派机制(注意,但是没有破坏双亲委派,),先从自定义加载器中加载请求
自定义类加载在实际工作当中是很少甚至基本不使用的,但是特殊场景还是会用到的

实现流程
1.创建一个自定义加载器类 继承 ClassLoader 类
2.重写 findClass 方法。 主要是实现从那个路径读取 jar包或者.class文件,将读取到的文件用字节数组来存储,然后可以使用父类的 defineClass 来转换成字节码。
如果想破坏双亲委派的话,就重写 loadClass 方法, 否则不用重写,注意(实际当中一般重写 findClass 即可,不重写loadClass)
建议的做法是只在findClass()里重写自定义类的加载方法,根据参数指定类的名字,返回对应的Class对象的引用。
3,loadclass():实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。

ClassLoader提供的 protected finalClass<?> defineClass(String name, byte[] b, int off, int len) 是用来将字节数组转换成字节码文件,传入参数 是(类名,字节数组数据,字节数组读取的开始下标,字节数组的长度),在重写方法findClass方法中调用此方法

示例如下:
1,在如下目录,生成一个Hello.class字节码文件
在这里插入图片描述

2,读取上述Hello.class字节码文件
创建一个名为MyClassLoader的类加载器

import java.io.*;
public class MyClassLoader extends ClassLoader{
    
    
    String path ;
    MyClassLoader(String dir){
    
    
        this.path = dir ;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
    
    
        try {
    
    
            this.path = this.path + name +".class";
            File f = new File(path);
            InputStream in = new FileInputStream(f);
            byte [] bys = new byte[  (int)f.length() ];
            int len = 0;
            while( (len = in.read(bys) )!= -1  ){
    
    }
            // byte[] -> .class
            // 主要是调用这个方法,生成对应的Class<?> 实例对象
            return defineClass(name,bys,0,bys.length);
        } catch (FileNotFoundException e) {
    
    
            e.printStackTrace();
        } catch (IOException e) {
    
    
            e.printStackTrace();
        }
        return super.findClass(name);
    }
}

测试:

import java.lang.reflect.Method;

public class Test {
    
    
    public static void main(String[] args) throws Exception {
    
    
        MyClassLoader my = new MyClassLoader("D:\\test\\jvmtest\\");
        // 实质上即就是反射
        Class<?> c1 = my.loadClass("Hello");
        Object o = c1.newInstance();
        Method d = c1.getMethod("sayHello",null);
        d.invoke(o);
    }
}

运行结果如图所示:
加粗样式

.

6. 垃圾回收器

在JVM虚拟机中,分配的内存用完后的回收、释放操作是不需要开发人员处理的,由JVM内存模型中的垃圾回收器帮我们处理完成
垃圾收集器是垃圾回收算法(引用计数法、标记清楚法、标记整理法、复制算法)的具体实现

新生代、老年代、永久代

在 Java 中,堆被划分成两个大的不同区域:新生代 ( Young )、老年代 ( Old ),默认比例 2:1

新生代

新生代中区域划分为3块,Eden区、From区、To区,默认8:1:1。
Eden区回收对象是非常频繁的,回收速度快,所以设置的堆内存所占比例比较大

老年代

老年代存放的是大对象,回收频次比较低,老年代如频次回收对象,是非常消耗性能的。

MinorGC、MajorGC、FullGC区别

Minor GC是发生在新生代的垃圾回收。由于java对象大都是朝生夕死的,所以Minor GC非常频繁,一般回收速度也比较快。
Major GC是发生在老年代的垃圾回收,通常执行Major GC会连着Minor GC一起执行。Major GC的速度要比Minor GC慢的多。
Full GC是清理整个堆空间,包括年轻代和老年代

什么对象才能直接进入老年代

1,大对象直接进入老年代。
2,动态年龄判断
3,年龄大于阈值,进入老年代Minor GC后,存活的对象空间大于survivor空间,直接进入老年代。

动态年龄判断原理

这里是引用

永久代

永久代就是JVM的方法区。在这里都是放着一些被虚拟机加载的类信息,静态变量,常量等数据。这个区中的东西比老年代和新生代更不容易回收

JVM中堆为啥要划分区域?

划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收

垃圾回收机制

1, finalize方法

垃圾回收机制用到finalize。当程序创建对象、数组等引用类型实体时,系统都会在堆内存中为之分 配一块内存区,对象就保存在这块内存中,当这块内存不再被任何引用变量引用时,这块内存就会 变成垃圾,等待垃圾回收机制进行回收。

2,分析对象是否为垃圾

分析对象是否为垃圾的方法 : 可达性分析法, 引用计数法两种

3,强制回收

当一个对象失去引用后,系统何时调用它的finalize ()方法对它进行资源清理,何时它会变成不可达状态,系统何时回收它所占有的内存。对于系统程序完全透明。
.
程序只能控制一个 对象任何不再被任何引用变量引用,绝不能控制它何时被回收。
.
制只是建议系统立即进行垃圾回收,系统完全有可能并不立即进行垃圾回收,垃圾回收机制也不会对程序的建议置之不理:垃圾回收 机制会在收到通知后,尽快进行垃圾回收
强制垃圾回收调用System类的gc()

4,垃圾回收算法

垃圾回收基本算法有四种 : 引用计数法, 标记清除法, 标记压缩法, 复制算法
垃圾回收复合算法 – 分代收集算法:当前虚拟机的垃圾收集都采用分代收集算法

YoungGC、FullGC触发机制

Young GC

Young GC :Young GC其实一般就是在新生代的Eden区域满了之后就会触发,采用复制算法来回
收新生代的垃圾。

Full GC

Full GC : 调用System.gc()时。
触发时机 : 老年代空间不足、 方法区空间不足 、进入老年代的平均大小大于老年代的可用内存由Eden区的幸存者0区向幸存者1区复制时,对象大小大于1区可用内存,则把该对象转存到 老年代,且老年代的可用内存大小小于该对象大小。
注意:full GC是开发或调优中尽量要避免的,这样STW会短一些

FullGC触发问题

fullgc的时候除gc线程外的所有用户线程处于暂停状态,也就是不会有响应了。
一般fullgc速度很快,毫秒级 的,用户无感知。除非内存特别大上百G的,或者fullgc也无法收集到足够内存导致一直fullgc,应用的外在表 现就是程序卡死了.

标记垃圾的算法

标记垃圾实质上就是判断对象是否存活

引用计数法(已淘来)

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;
当引用失效时,计数器值就减1;
任何时刻计数器为0的对象就是不可能再被使用的。
存在循环引用问题

可达性分析

1,目前主流编程语言实现中,都是称通过可达性分析(Reachability Analysis)来判 定对象是否存活的。
2,这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,当一个对象到GC Roots没有任何引用链相连就是从GC Roots到这个对象不可达时,则证明此对象是不可用的。

可达性分析的有点解决相互循环引用问题,速度也快

可以作为GC Roots 的对象有哪些

1、虚拟机栈(栈帧中的本地变量表)中引用的对象;
2、方法区中类静态属于引用的对象;
3、方法区中常量引用的对象;
4、本地方法栈中JNI(即一般说的Native方法)引用的对象

Java对象引用

在Java语言中,将引用又分为强引用、软引用、弱引用、虚引用4种,这四种引用强度依次逐渐减弱。通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与Java种的“引用”有关

类型 说明
强引用 创建一个对象,并把这个对象赋值给一个引用变量,JVM永远不会对其回收
软引用(SoftReference) 一个对象如果是软引用,当堆内存足够时,垃圾回收器就不会对其进行回收,只有在内存空间不足,要迫切释放空间的时候,jvm才会释放软引用的对象,软引用可用来实现内存敏感的高速缓存,比如网页缓存、图片缓存等。使用软引用能防止内存泄露,增强程序的健壮性。
弱引用(PhantomReference) 要GC,就会回收
虚引用 对于引用对象来说如同虚设,主要用来跟踪对象被垃圾回收的活动。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把虚引用加入到引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前进行垃圾回收操作

垃圾回收的算法

标记-清除

使用可达性分析算法标记内存中的垃圾对象,并直接清理,速度快,容易造成空间碎片

标记-压缩

在标记-清除算法的基础上,将存活的对象整理在一起保证空间的连续性,速度慢,不存在内存碎、片问题

复制-清除

将内存空间划分成等大的两块区域,from和to只用其中的一块区域,收集垃圾时将存活的对象复制到另一块区域中,并清空使用的区域;解决了空间碎片的问题,但是空间的利用率低

分代收集

将内存空间分为老年代和新生代,新生代中存储一些生命周期短的对象,使用复制-清除算法;而老年代中对象的生命周期长,则使用标记-清除或标记-整 理算法

垃圾收集器分类

单线程串形Serial、Serial Old垃圾回收器

单线程 串形垃圾回收器,Serial针对新生代,采用标记清除算法Serial Old针对老年代采用标记压缩算法

多线程串形Parallel、Parallel Old垃圾回收器

多线程串形垃圾回收器,Parallel针对新生代,采用标记清除算法Parallel Old针对老年代,采用标记压缩算法

多线程串行ParNew垃圾回收器

多线程串行垃圾回收器,针对新生代,采用复制清除算法
目前只有ParNew GC能与CMS收集器配合工作

多线程并行CMS回收器(针对老年代、永久代)

CMS是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。
.
CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候会产生大量的内存碎片,当剩余内存不能满足 程序运行要求时,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。另外内存使用率到达70%,就开始进行GC操作,内存占用率不高
.
CMS并发执行对CPU资源消耗大。

CMS 工作机制, 整个过程分为 4 个阶段:
(1)初始标记

只是标记一下 GC Roots 对象,速度很快,仍然需要暂停所有的工作线程。

(2)并发标记

进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。

(3)重新标记

为了修正在并发标记期间可能产生的错标、漏标情况,仍然需要暂停所有的工作线程。

解释一下错标、漏标的情况
1,错标:就是本来应该被回收的对象,但是用户线程却引用了这个对象,导致被GRoot标记了
2,漏标:就是本来不会被回收的对象,但是用户线程此时却不在引用这个对象,导致没有被GRoot标记。

(4)并发清除

清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程,垃圾收集线程可以和用户现在一起并发工作

多线程串形G1垃圾回收器(针对年轻代、老年代)

G1针对新生代、老年代同时进行回收,新生代采用复制清除,老年代采用标记压缩
G1采用标记-压缩算法不会出现内存碎片化问题
G1将内存区域分成2000多个region,每次并行回收几个区域
G1在逻辑层面上是区分年轻代、老年代,物理层面上是不区分的

GC的三色标记法

对于三色标记算法而言, 对象会根据是否被访问过(也就是是否在可达性分析过程中被检查过)被分为三个颜色:白色、灰色和黑色:

1,白色:当前对象还没有被GC Root标记过。(没有标记
2,灰色:当前对象已经被GC Root标记过,但是当前对象所直接引用的对象中,至少还有一个没有被访问到(没有向下标记
3,黑色:对象和它所直接引用的所有对象都被GC Root访问过 (有标记,且有向下标记

对象内存分配流程

首先进行逃逸分析、是否可以栈上分配
不可以栈上分配的话,判断对象是否是大对象
不是大对象的话,查看年轻代的Eden区的TLAB区域内存是否充足
不充足的话,年轻代`进行一次Young GC,尝试在Eden区开辟一段空间

放不下,就放入老年代中开辟空间
老年代放不下,执行Full GC,在尝试在老年代开辟空间
还放不下,直接抛OOM异常

逃逸分析:分析对象作用域是否逃出函数体
TLAB:是一小块为每个线程优先开辟的缓存空间

7. JAVA内存模型

Java 内存模型(简称 JMM):定义了线程和主内存之间的抽象关系,
JMM内存模型是一个规范,这个规范能保证共享内存读写的正确性。

JVM与JMM的区别?

JMM定义了JVM在计算机内存中的工作方式,JVM是将运行时数据分区存储,强调的是空间的划分

为什么要用到JMM内存模型?

1,这里需要了解一下硬件内存结构存在的不足

1,传统的计算机中主内存和处理器是直接交互的,后来由于主存与 CPU 处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运算结束后 CPU 再讲运算结果同步到主存中。
2,使用高速缓存解决了 CPU 和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题。(CPU 会将数据读取到缓存中进行运算,这可能会导致各自的缓存数据不一致)
3,数据同时存在于高速缓存和主内存中,如果不加以规范势必造成灾难,因此在传统机器上又抽象出了内存模型。

因此,在传统机器上抽象出了内存模型,关系图如下图所示:
在这里插入图片描述

JMM内存模型定义规范

1,所有的变量都存储在主内存中
2,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的拷贝副本
3,线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存
4,不同的线程之间无法直接访问对方本地内存中的变量

线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如图:
在这里插入图片描述

因此,JMM(java memory model)java内存模型总体概述如下:

MM规定了所有变量(除了方法参数和本地变量,包括实例变量和静态变量)都放在主内存中
每个线程都有自己的工作内存,工作内存保存了该线程使用的主内存的变量副本
所有的操作都在工作内存中进行,线程不能直接操作主内存。线程之间通过将数据刷回主内存的方式进行通信

多线程并发的情况下的,Java多线程遵循的三大特点:原子性、可见性、有序性。

1,上述讲到的保持缓存一致性问题实质上就是可见性、指令重排就会导致有序性
2,为了解决这个三大问题,JMM内存模型保障针对这三大问题都有相应的处理方案:
保证原子性synchronized,线程安全的对象concurrent包 、lock+unlock
保证可见性volatile、synchronized
保证有序性volatile:关键字来保证一定的“有序性”、synchronized、lock+unlock
3,Java 语言在遵循内存模型的基础上推出了 JMM 规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序导致乱序执行等带来的问题。

8. JVM可视化工具

JVM可视化工具是用来监控正在运行的JAVA应用系统的整体状态

原由、目的

1,开发大型 Java应用程序的过程中难免遇到内存泄露、性能瓶颈等问题,比如文件、网络、数据库的连接未释放,未优化的算法等。
2,随着应用程序的持续运行,可能会造成整个系统运行效率下降,严重的则会造成系统崩溃。
3,为了找出程序中隐藏的这些问题,在项目开发后期往往会使用性能分析工具来对应用程序的性能进行分析和优化。

JConsole

JVisualVM

9. 性能调优

思路

1,对于还在正常运行的系统

1. 可以使⽤jmap来查看JVM中各个区域的使⽤情况
2. 可以通过jstack来查看线程的运⾏情况,⽐如哪些线程阻塞、是否出现了死锁
3. 可以通过jstat命令来查看垃圾回收的情况,特别是fullgc,如果发现fullgc⽐较频繁,那么就得进⾏调优了
4. 通过各个命令的结果,或者jvisualvm等⼯具来进⾏分析
5. ⾸先,初步猜测频繁发送fullgc的原因,如果频繁发⽣fullgc但是⼜⼀直没有出现内存溢出,那么表示 fullgc实际上是回收了很多对象了,所以这些对象最好能在younggc过程中就直接回收掉,避免这些对 象进⼊到⽼年代,对于这种情况,就要考虑这些存活时间不⻓的对象是不是⽐较⼤,导致年轻代放不 下,直接进⼊到了⽼年代,尝试加⼤年轻代的⼤⼩,如果改完之后,fullgc减少,则证明修改有效
6. 同时,还可以找到占⽤CPU最多的线程,定位到具体的⽅法,优化这个⽅法的执⾏,看是否能避免某些 对象的创建,从⽽节省内存

2,对发生OOM异常的系统

⼀般⽣产系统中都会设置当系统发⽣了OOM时,⽣成当时的dump文件(XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/base)
2. 我们可以利⽤jsisualvm等⼯具来分析dump⽂件
3. 根据dump⽂件找到异常的实例对象,和异常的线程(占⽤CPU⾼), 定位到具体的代码
4. 然后再进⾏详细的分析和调试

常用分析参数

1、Jps : 用于查询正在运行的JVM进程。 直接获取进程的pid
2、Jstat : 可以实时显示本地或远程JVM进程中装载,内存,垃圾信息,JIT编译等数据
3、Jinfo : 用于查询当前运行着的进程属性
4、Jmap : 用于显示当前java堆和永生代的详细信息
5、Jvisualvm : 用于分析使用jmap生成的dump文件,是JDK自带的工具
6、Jstack: 用于生成当JVM所有线程快照,线程快照是虚拟机每一条线程正在执行的方法,目的是定位线程出现长时间停顿的原因

常用调优参数

-Xmx 指定java程序的最大堆内存
.
-Xms 指定最小堆内存
.
-Xmn 设置年轻代大小 , 整个堆大小=年轻代大小+年老代大小。所以增大年轻代后,会减小年老代大小。此值对系统影响较大,Sun官方推荐为整个堆的3/8
.
-Xss 指定线程的最大栈空间,此参数决定了java函数调用的深度,值越大说明调用深度越深,若值太小则容易栈溢出错误(StackOverflowError)
.
-XX: PermSize 指定方法区(永久区)的初始值默认是物理内存的1/64。
.
-XX:MetaspaceSize指定元数据区大小, 在Java8中,永久区被移除,代之的是元数据区
.
-XX:NewRatio=n 年老代与年轻代的比值,-XX:NewRatio=2,表示年老代和年轻代的比值为2:1
.
-XX:SurvivorRatio=n Eden区与Survivor区的大小比值,-XX:SurvivorRatio=8表示Eden区与
Survivor区的大小比值是8:1:1,因为Survivor区有两个(from,to);

分析生成Dump文件的常用参数

-XX:+PrintGCDetails
-XX:+UseConcMarkSweepGC
-Xmx20m
-Xms20m

-XX:+HeapDumpOnOutOfMemoryError
-XX:+HeapDumpBeforeFullGC
-XX:+HeapDumpAfterFullGC
-XX:HeapDumpPath: D://Log

注意:系统OOM生成的dump文件,是在IDea中加入参数指令,然后可以在JvisualVM中导入文件,如下所示
在这里插入图片描述
可以看到后缀名为:hprof的Dump文件
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_45399396/article/details/128921072