avatar

目录
JVM基础

JVM基础

简介:

本文介绍了一些常见的JVM中的面试题


Java 中都有哪些引用类型?

  • 强引用:发生 gc 的时候不会被回收。

    之前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。比如下面这段代码中的object和str都是强引用:

    Object object = new Object();
    String str = "StrongReference";
  • 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。在Java中用java.lang.ref.SoftReference类来表示。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。

  • 弱引用:有用但不是必须的对象,在下一次GC时会被回收。应用场景:如果一个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么应该用 Weak Reference 来记住此对象。或者想引用一个对象,但是这个对象有自己的生命周期,你不想介入这个对象的生命周期,这时候就应该用弱引用,这个引用不会在对象的垃圾回收判断中产生任何附加的影响。

  • 虚引用:和前面的软引用、弱引用不同,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收的活动。

Java内存区域

说一下 JVM 的主要组成部分及其作用?

image-20200607182848130

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(本地接口):与native libraries交互,是其它编程语言交互的接口。
  • Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

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

JVM的方法区和永久带是什么关系?

(1)方法区是规范层面的东西,规定了这一个区域要存放哪些东西

(2)永久带或者是metaspace是对方法区的不同实现,是实现层面的东西。

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。间接性地解决了永久代的OOM问题。

    MetaspaceSize:初始化元空间大小,控制发生GC
    MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。
  2. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

CodeCache

JIT简介

JIT是just in time的缩写,也就是即时编译。通过JIT技术,能够做到Java程序执行速度的加速。那么,是怎么做到的呢?

我们都知道,Java是一门解释型语言(或者说是半编译,半解释型语言)。Java通过编译器javac先将源程序编译成与平台无关的Java字节码文件(.class),再由JVM解释执行字节码文件,从而做到平台无关。 但是,有利必有弊。对字节码的解释执行过程实质为:JVM先将字节码翻译为对应的机器指令,然后执行机器指令。很显然,这样经过解释执行,其执行速度必然不如直接执行二进制字节码文件。

而为了提高执行速度,便引入了 JIT 技术。通过热点探测当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”编译成本地机器相关的机器码,并进行优化,然后再把编译后的机器码缓存起来,以备下次使用。

img

codecache代码缓存区,主要存放JIT所编译的代码,同时还有Java所使用的本地方法代码也会存储在codecache中.

相关参数:

-XX:ReservedCodeCacheSize :设置codeCache的size大小

一种推荐的设置思路是设置为当前值(或者默认值)的2倍 对于64位jvm,由于内存空间足够大,codeCache设置的过大不会对应用产生明显影响

-XX:+UseCodeCacheFlushing :启用code cache的回收机制。

  • 当codeCache将要耗尽时,最早被编译的一半方法将会被放到一个old列表中等待回收,在一定时间间隔内,如果方法没有被调用,这个方法就会被从codeCache充清除

在jdk8中,提供了一个启动参数XX:+PrintCodeCache在jvm停止的时候打印出codeCache的使用情况

由于JIT是随着代码被调用的次数达到CompileThreshold之后进行的,因此,codecache使用也会随之增加,常见的问题就是,随着时间的推移应用占用CPU会随之增高,请求相应变慢等问题,这时就需要考虑codecache的问题了。

说一下 JVM 运行时数据区

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:

image-20200610123657999

分别介绍

  • 程序计数器:程序计数器其实就是一个指针,它指向了我们程序中下一句需要执行的指令,它也是内存区域中唯一一个不会出现OutOfMemoryError的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。
  • 虚拟机栈:用于存储局部变量表、操作数栈、动态链接、方法出口等信息;
    • 每个栈帧都存放以上4个信息,每个栈帧是一个方法
    • 局部变量表,存放局部变量,例如int a=2;
    • 操作数栈存放帮助栈帧进行运算的临时操作数。
    • 动态链接:通过javap命令查看class文件,可以看到很多符号引用,在类加载的过程(静态链接)或运行过程会将部分符号引用在运行期间转化为直接引用。直接引用可被jvm通过命令调用。
  • 本地方法栈:与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;
  • Java堆:Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;
  • 方法区:用于存储已被虚拟机加载的类元信息、常量、静态变量、即时编译后的代码等数据。

image-20200610124342656

Java 虚拟机栈会出现两种错误:

StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。

Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

虚拟机栈和虚拟机堆区别

一句话便是:栈管运行,堆管存储。则虚拟机栈负责运行代码,而虚拟机堆负责存储数据。

Java文件完整的加载运行过程

一个简单的学生类 img

一个main方法 img

执行main方法的步骤如下:

  1. 编译好 App.java 后得到 App.class 后,执行 App.class,系统会启动一个 JVM 进程,从 classpath 路径中找到一个名为 App.class 的二进制文件,将 App 的类信息加载到运行时数据区的方法区内,这个过程叫做 App 类的加载
  2. JVM 找到 App 的主程序入口,执行main方法
  3. 这个main中的第一条语句为 Student student = new Student(“tellUrDream”) ,就是让 JVM 创建一个Student对象,但是这个时候方法区中是没有 Student 类的信息的,所以 JVM 马上加载 Student 类,把 Student 类的信息放到方法区中
  4. 加载完 Student 类后,JVM 在堆中为一个新的 Student 实例分配内存,然后调用构造函数初始化 Student 实例,这个 Student 实例持有 指向方法区中的 Student 类的元信息 的引用
  5. 执行student.sayName();时,JVM 根据 student 的引用找到 student 对象,然后根据 student 对象持有的引用定位到方法区中 student 类的类型信息的方法表,获得 sayName() 的字节码地址。
  6. 执行sayName()

其实也不用管太多,只需要知道对象实例初始化时会去方法区中找类信息,完成后再到栈那里去运行方法。找方法就在方法表中找。

类加载器

之前也提到了它是负责加载.class文件的,它们在文件开头会有特定的文件标示,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构,并且ClassLoader只负责class文件的加载,而是否能够运行则由 Execution Engine 来决定

类加载器的流程(加校准解初使卸)

从类被加载到虚拟机内存中开始,到释放内存总共有7个步骤:加载,校验,准备,解析,初始化,使用,卸载。其中验证,准备,解析三个部分统称为连接

加载

  1. 将class文件加载到内存
  2. 将静态数据结构转化成方法区中运行时的数据结构
  3. 在堆中生成一个代表这个类的 java.lang.Class对象作为数据访问的入口

链接

  1. 验证:确保加载的类符合 JVM 规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查
  2. 准备:为static变量在方法区中分配内存空间,设置变量的初始值,例如 static int a = 3 (注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的),但这里只是赋值为int a=0,即根据数据类型符默认值。
  3. 解析:虚拟机将常量池内的符号引用替换为直接引用的过程(符号引用比如我现在import java.util.ArrayList这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行)

初始化

初始化其实就是一个赋值的操作,它会执行一个类构造器的()方法。由编译器自动收集类中所有变量的赋值动作,此时准备阶段时的那个 static int a = 3 的例子,在这个时候就正式赋值为3

卸载

GC将无用对象从内存中卸载

类加载器的加载顺序

加载一个Class类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的

  1. 启动类加载器:BootStrap ClassLoader:rt.jar
  2. 拓展类加载器:Extention ClassLoader: 加载扩展的jar包
  3. 应用类加载器:App ClassLoader:指定的classpath下面的jar包
  4. 自定义类加载器:Custom ClassLoader

双亲委派机制

当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成,比如我现在要new一个Person,这个Person是我们自定义的类,如果我们要加载它,就会先委派App ClassLoader,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的Class)时,子类加载器才会自行尝试加载

这样做的好处是,加载位于rt.jar包中的类时不管是哪个加载器加载,最终都会委托到BootStrap ClassLoader进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。

其实这个也是一个隔离的作用,避免了我们的代码影响了JDK的代码。

自定义ClassLoader

基本用法

Java类加载机制的强大之处在于,我们可以创建自定义的ClassLoader,自定义ClassLoader是Tomcat实现应用隔离、支持JSP,OSGI实现动态模块化的基础。

怎么自定义呢?一般而言,继承类ClassLoader,重写findClass就可以了。怎么实现findClass呢?使用自己的逻辑寻找class文件字节码的字节形式,找到后,使用如下方法转换为Class对象:

protected final Class<?> defineClass(String name, byte[] b, int off, int len)

name表示类名,b是存放字节码数据的字节数组,有效数据从off开始,长度为len。

看个例子:

    public class MyClassLoader extends ClassLoader {
    private static final String BASE_DIR = "data/c87/";
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String fileName = name.replaceAll("\\.", "/");
        fileName = BASE_DIR + fileName + ".class";
        try {
            byte[] bytes = BinaryFileUtils.readFileToByteArray(fileName);
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException ex) {
            throw new ClassNotFoundException("failed to load class " + name, ex);
        }
    }
}

MyClassLoader从BASE_DIR下的路径中加载类,转换为byte数组。MyClassLoader没有指定父ClassLoader,默认是系统类加载器,即ClassLoader.getSystemClassLoader()的返回值,不过,ClassLoader有一个可重写的构造方法,可以指定父ClassLoader:protected ClassLoader(ClassLoader parent)

用途

这到底有什么用呢?

  • 可以实现隔离,一个复杂的程序,内部可能按模块组织,不同模块可能使用同一个类,但使用的是不同版本,如果使用同一个类加载器,它们是无法共存的,不同模块使用不同的类加载器就可以实现隔离,Tomcat使用它隔离不同的Web应用,OSGI使用它隔离不同模块。
  • 可以实现热部署,使用同一个ClassLoader,类只会被加载一次,加载后,即使class文件已经变了,再次加载,得到的也还是原来的Class对象,而使用MyClassLoader,则可以先创建一个新的ClassLoader,再用它加载Class,得到的Class对象就是新的,从而实现动态更新。

虚拟机堆

组成

image-20200812110120377

​ JVM内存会划分为堆内存和非堆内存,堆内存中也会划分为年轻代老年代,默认比例是1:2,而非堆内存则为永久代()。年轻代又会分为EdenSurvivor区。Survivor也会分为FromPlaceToPlace,toPlace的survivor区域是空的。Eden,FromPlace和ToPlace的默认占比为 8:1:1

垃圾回收的过程

  1. 当我们new一个对象后,会先放到Eden划分出来的一块作为存储空间的内存,但是我们知道对堆内存是线程共享的,所以有可能会出现两个对象共用一个内存的情况。这里JVM的处理是每个线程都会预先申请好一块连续的内存空间并规定了对象存放的位置。

  2. 当Eden空间满了之后,会触发一个叫做Minor GC(就是一个发生在年轻代的GC)的操作,存活下来的对象移动到Survivor0区。Survivor0区满后触发 Minor GC,就会将存活对象移动到Survivor1区,此时还会把from和to两个指针交换,这样保证了一段时间内总有一个survivor区为空且to所指向的survivor区为空。

  3. 经过多次的 Minor GC后仍然存活的对象(这里的存活判断是15次,对应虚拟机参数XX:MaxTenuringThreshold 。为什么是15,因为HotSpot会在对象投中的标记字段里记录年龄,分配到的空间仅有4位,所以最多只能记录到15)会移动到老年代。

  4. 老年代是存储长期存活的对象的,占满时就会触发我们最常听说的Full GC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用应该尽量去减少发生Full GC从而避免响应超时的问题。

  5. 当老年区执行了full gc之后仍然无法进行对象保存的操作,就会产生OOM,这时候就是虚拟机中的堆内存不足,原因可能会是堆内存设置的大小过小,这个可以通过参数-Xms、-Xmx来调整。也可能是代码中创建的对象大且多,而且它们一直在被引用从而长时间垃圾收集无法收集它们。JVM默认使用系统内存的1/4。

img

动态年龄判断

​ 一句话:当某年龄在survivor区占比超过预设值时,大于等于该年龄的对象会直接进行老年代,无需等到MaxTenuringThreshold中要求的15。

​ 设定的参数为:-XX:TargetSurvivorRatio

如何判断一个对象需要被干掉

img

​ 程序计数器、虚拟机栈、本地方法栈,3个区域随着线程的生存而生存的。内存分配和回收都是确定的。随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收的问题。而Java堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法这部分内存。

在进行回收前就要判断哪些对象还存活,哪些已经死去。下面介绍两个基础的计算方法:

1.引用计数器:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于0时就是不会再次使用的。不过这个方法有一种情况就是出现对象的循环引用时GC没法回收。

2.可达性分析:这是一种类似于二叉树的实现,将一系列的GC ROOTS作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。搜索当一个对象到GC Roots没有使用任何引用链时,则说明该对象是不可用的。

能作为GC Roots的对象分为以下几种:

  1. 虚拟机栈中引用的对象(局部变量)
  2. 本地方法栈中引用的对象
  3. 方法区中静态变量所引用的对象和常量引用的对象(静态变量)

这种方法的优点是能够解决循环引用的问题,可它的实现需要耗费大量资源和时间,也需要GC(它的分析过程引用关系不能发生变化,所以需要停止所有进程)

如何宣告一个对象的真正死亡

判断一个对象的死亡至少需要两次标记

  1. 如果对象进行可达性分析之后没发现与GC Roots相连的引用链,那它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否重写了finalize()方法。如果对象重写了finalize()方法,则被放入F-Queue队列中。
  2. GC对F-Queue队列中的对象进行二次标记。如果对象在finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。

逃逸分析

image-20200610210559246

例如:

开启逃逸分析后test2中的user对象,直接在栈内存中创建,当方法完成后直接被垃圾回收。jdk7以后默认开启逃逸分析。

image-20200610210625390

垃圾回收算法

常用的有标记清除,复制,标记整理和分代收集算法

复制算法

Hotspot JVM把年轻代分为了三部分:1个Eden区和2个 Survivor区(分别叫from和to),默认比例为8:1:1,一般情况下 新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次 Minor gc后,如果仍然存活,将会被移到 Survivor区。对象在 Survivor区中每熬过一次 Minor gc,年齡就会增加1岁,当它的年齡(15默认)增加到一定程度时,就会被移动到 年老代中。因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。

好处:复制算法不会产生内存碎片,对象完整不会丢。

缺点:浪费了10%空间。

img

标记清除算法

标记清除算法就是分为“标记”和“清除”两个阶段。标记出所有需要回收的对象,标记结束后统一回收

其实它就是把已死亡的对象标记为空闲内存,然后记录在一个空闲列表中,当我们需要new一个对象时,内存管理模块会从空闲列表中寻找空闲的内存来分给新的对象。

不足的方面就是标记和清除的效率比较低下。且这种做法会让内存中的碎片非常多。这个导致了如果我们需要使用到较大的内存块时,无法分配到足够的连续内存。比如下图

img

此时可使用的内存块都是零零散散的,导致了刚刚提到的大内存对象问题

标记整理算法

复制算法在对象存活率高的时候会有一定的效率问题,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

标记整理算法唯一的缺点就是效率也不髙,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记整理算法要低于复制算法。

img

分代收集算法

这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

说白了就是八仙过海各显神通,具体问题具体分析了而已。

垃圾回收器

HotSpot VM中的垃圾回收器,以及适用场景 img

串行:Serial(新生代) ,Serial Old(老年代),适用于单CPU的Client模式

并行:Parallel Scavenge (新生代)和 Parallel Old(老年代),侧重于吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间))优先,是jdk8默认的垃圾收集器,所以更适合做后台运算等不需要太多用户交互的任务。Parallel Old使用多线程和标记整理法

并行:ParNew(新生代)和CMS(老年代),侧重于响应速度优先。

ParNew:

多线程版的 Serial。多线程可以让垃圾回收得更快,也就是减少了 STW 时间,能提升响应时间,只有它能与 CMS 收集器配合工作

CMS垃圾回收器

关注点是获得最短的回收停顿时间,用的是“标记-清除”算法,它运作过程分为四个步骤:

  • 初始标记(CMS initial mark)- Stop The World,标记GC_ROOT根节点及其子节点,时间短
  • 并发标记(CMS concurrent mark),标记第一步接下来的节点。
  • 重新标记(CMS remark)- Stop The World:补充标记前两步后新生成的节点。
  • 并发清除(CMS concurrent sweep),

其中并发标记、并发清除时间是相对较长的,都是可以和用户线程并发执行的,所以Stop The World时间是很短的,总体上来看就是并发执行的,这对要求响应速度较快的应用场景比较适合。

CMS缺点:

  • 对CPU资源敏感,抢占CPU资源将导致用户线程的CPU资源减少而变得缓慢;
  • 无法处理浮动垃圾,在并发回收垃圾时,用户线程会产生新的垃圾对象,这些垃圾要等下次回收;
  • 由于在并发回收的过程用户线程还在工作,这就需要预留一定的内存空间给用户线程,导致内存空间利用率下降;然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction 来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 收集器来重新进行老年代的收集,而我们知道 Serial Old 收集器是单线程收集器,这样就会导致 STW 更长了。
  • CMS采用的是标记-清除算法,这就导致内存碎片化。若出现内存空间还很多,但由于碎片化的情况,无法满足大对象的分配,当顶不住要触发Full GC时开启内存碎片合并整理过程,这个过程是不能并发的,会Stop The World。

到jdk8为止,默认的垃圾收集器是Parallel Scavenge 和 Parallel Old

从jdk9开始,G1收集器成为默认的垃圾收集器 目前来看,G1回收器停顿时间最短而且没有明显缺点,非常适合Web应用。在jdk8中测试Web应用,堆内存6G,新生代4.5G的情况下,Parallel Scavenge 回收新生代停顿长达1.5秒。G1回收器回收同样大小的新生代只停顿0.2秒。

image-20200611140259211

G1 垃圾收集器

​ 通过引入 Region 的概念,从而将原来的整块堆内存空间划分成2048个的小空间,使得每个小空间可以单独进行垃圾回收。每块可作为Eden、Survivor、Old、Humongous中的一种。

​ 这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

​ 每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。在做可达性分析的时候就可以避免全堆扫描。

  • 初始标记
  • 并发标记
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

优点

  • 空间整理,与CMS标记-清理相比,它采用的是标记-整理,从局部Region来看又是复制算法;另外CMS在回收阶段将标记的垃圾全部回收,但G1根据设定参数,排序后,只回收优先级高的垃圾,缩短了垃圾回收的时间,保证了响应速度。
  • 可预测停顿,可以让使用这指定在长度为M毫秒的是时间内,垃圾收集时间不能超过N毫秒;
  • 年轻代初始赋值5%的内存空间,如果达到此空间,但还没达到设置的GC时间,就会动态增加年轻代的Region。

image-20200610154046331

当一个对象大于Region大小的50%,称为巨型对象;它就会独占一个或多个Region,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区-Humongous Region

JVM的常用参数

通用参数

-Xms

初始大小内存,默认为物理内存 1/64

等价于 -XX:InitialHeapSize

-Xmx

最大分配内存,默认为物理内存的 1/4

等价于 -XX:MaxHeapSize

-Xms和-Xmx最好一致,以避免每次垃圾回收完成后JVM重新分配内存。

-XX:SurvivorRatio

设置新生代中 eden 和 S0/S1 空间比例

默认 -XX:SurvivorRatio=8,Eden : S0 : S1 = 8 : 1 : 1

-XX:NewRatio

配置年轻代和老年代在堆结构的占比

默认 -XX:NewRatio=2 新生代占1,老年代占2,年轻代占整个堆的 1/3

-Xss

设置单个线程栈的大小,一般默认为 512-1024k

等价于 -XX:ThreadStackSize

在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

-XX:MetaspaceSize

设置元空间大小(元空间的本质和永久代类似,都是对 JVM 规范中的方法区的实现,不过元空间于永久代之间最大区别在于,元空间并不在虚拟中,而是使用本地内存,因此默认情况下,元空间的大小仅受本地内存限制)

元空间默认比较小,我们可以调大一点

-XX:MaxTenuringThreshold

设置垃圾最大年龄

-XX:+PrintGCDetails

输出详细 GC 收集日志信息

image-20200812112340234

G1 GC的参数选项

参数名 含义 默认值
-XX:+UseG1GC ==使用G1收集器== JDK1.8中还需要显式指定
-XX:MaxGCPauseMillis=n 设置一个==期望的最大GC暂停时间==,这是一个柔性的目标,JVM会尽力去达到这个目标 200
-XX:InitiatingHeapOccupancyPercent=n 当整个堆的空间使用百分比超过这个值时,就会触发一次并发收集周期,记住是整个堆 45
-XX:NewRatio=n 新生代和老年代的比例 2
-XX:SurvivorRatio=n Eden空间和Survivor空间的比例 8
-XX:MaxTenuringThreshold=n 对象在新生代中经历的最多的新生代收集,或者说最大的岁数 G1中是15
-XX:ParallelGCThreads=n 设置垃圾收集器的并行阶段的垃圾收集线程数 不同的平台有不同的值
-XX:ConcGCThreads=n 设置垃圾收集器并发执行GC的线程数 n一般是ParallelGCThreads的四分之一
-XX:G1ReservePercent=n 设置作为==空闲空间的预留内存百分比==,以降低目标空间溢出(疏散失败)的风险。默认值是 10%。增加或减少这个值,请确保对总的 Java 堆调整相同的量 10
-XX:G1HeapRegionSize=n 分区的大小 堆内存大小的1/2000,单位是MB,值是2的幂,范围是1MB到32MB之间
-XX:G1HeapWastePercent=n 设置您愿意浪费的堆百分比。如果可回收百分比小于堆废物百分比,JavaHotSpotVM不会启动混合垃圾回收周期(注意,这个参数可以用于调整混合收集的频率)。 JDK1.8是5
-XX:G1MixedGCCountTarget=8 设置并发周期后需要执行多少次混合收集,如果混合收集中STW的时间过长,可以考虑增大这个参数。(注意:这个可以用来调整每次混合收集中回收掉老年代分区的多少,即调节混合收集的停顿时间) 8
-XX:G1MixedGCLiveThresholdPercent=n 一个分区是否会被放入mix GC的CSet的阈值。对于一个分区来说,它的存活对象率如果超过这个比例,则改分区不会被列入mixed gc的CSet中 JDK1.6和1.7是65,JDK1.8是85

关于JVM调优的一些方面

我们可以尝试对JVM进行调优,主要就是堆内存那块

调整最大堆内存和最小堆内存

-Xmx –Xms:指定java堆最大值(默认值是物理内存的1/4(<1GB))和初始java堆最小值(默认值是物理内存的1/64(<1GB))

默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制.,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制。简单点来说,你不停地往堆内存里面丢数据,等它剩余大小小于40%了,JVM就会动态申请内存空间不过会小于-Xmx,如果剩余大小大于70%,又会动态缩小不过不会小于–Xms。就这么简单

开发过程中,通常会将 -Xms 与 -Xmx两个参数的配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。

我们执行下面的代码

System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");    //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");  //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");  //当前可用的总空间

注意:此处设置的是Java堆大小,也就是新生代大小 + 老年代大小 img

设置一个VM options的参数

-Xmx20m -Xms5m -XX:+PrintGCDetails

img

再次启动main方法

img 这里GC弹出了一个Allocation Failure分配失败,这个事情发生在PSYoungGen,也就是年轻代中

这时候申请到的内存为18M,空闲内存为4.214195251464844M

我们此时创建一个字节数组看看,执行下面的代码

byte[] b = new byte[1 * 1024 * 1024];
System.out.println("分配了1M空间给数组");
System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");  //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");  //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");

img

此时free memory就又缩水了,不过total memory是没有变化的。Java会尽可能将total mem的值维持在最小堆内存大小

byte[] b = new byte[10 * 1024 * 1024];
System.out.println("分配了10M空间给数组");
System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");  //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");  //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");  //当前可用的总空间

img

这时候我们创建了一个10M的字节数据,这时候最小堆内存是顶不住的。我们会发现现在的total memory已经变成了15M,这就是已经申请了一次内存的结果。

此时我们再跑一下这个代码

System.gc();
System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");    //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");  //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");  //当前可用的总空间

img

此时我们手动执行了一次fullgc,此时total memory的内存空间又变回5.5M了,此时又是把申请的内存释放掉的结果。

调整新生代和老年代的比值

-XX:NewRatio — 新生代(eden+2*Survivor)和老年代(不包含永久区)的比值

例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的1/5。在Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。

调整Survivor区和Eden区的比值

-XX:SurvivorRatio(幸存代)— 设置两个Survivor区和eden的比值

例如:8,表示两个Survivor:eden=2:8,即一个Survivor占年轻代的1/10

设置年轻代和老年代的大小

-XX:NewSize — 设置年轻代大小

-XX:MaxNewSize — 设置年轻代最大值

可以通过设置不同参数来测试不同的情况,反正最优解当然就是官方的Eden和Survivor的占比为8:1:1,然后在刚刚介绍这些参数的时候都已经附带了一些说明,感兴趣的也可以看看。反正最大堆内存和最小堆内存如果数值不同会导致多次的gc,需要注意。

小总结

根据实际事情调整新生代和幸存代的大小,官方推荐新生代占java堆的3/8,幸存代占新生代的1/10

在OOM时,记得Dump出堆,确保可以排查现场问题,通过下面命令你可以输出一个.dump文件,这个文件可以使用VisualVM或者Java自带的Java VisualVM工具。

-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=你要输出的日志路径

一般我们也可以通过编写脚本的方式来让OOM出现时给我们报个信,可以通过发送邮件或者重启程序等来解决。

永久区的设置

-XX:PermSize -XX:MaxPermSize

初始空间(默认为物理内存的1/64)和最大空间(默认为物理内存的1/4)。也就是说,jvm启动时,永久区一开始就占用了PermSize大小的空间,如果空间还不够,可以继续扩展,但是不能超过MaxPermSize,否则会OOM。

tips:如果堆空间没有用完也抛出了OOM,有可能是永久区导致的。堆空间实际占用非常少,但是永久区溢出 一样抛出OOM。

JVM的栈参数调优

调整每个线程栈空间的大小

可以通过-Xss:调整每个线程栈空间的大小

JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右

设置线程栈的大小

-XXThreadStackSize:
    设置线程栈的大小(0 means use default stack size)

这些参数都是可以通过自己编写程序去简单测试的,这里碍于篇幅问题就不再提供demo了

JVM其他参数介绍

形形色色的参数很多,就不会说把所有都扯个遍了,因为大家其实也不会说一定要去深究到底。

设置垃圾最大年龄

-XX:MaxTenuringThreshold
    设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代.
    对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,
    则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,
    增加在年轻代即被回收的概率。该参数只有在串行GC时才有效.

设置堆空间存活时间

-XX:SoftRefLRUPolicyMSPerMB
    设置每兆堆空闲空间中SoftReference的存活时间,默认值是1s。

设置对象直接分配在老年代

-XX:PretenureSizeThreshold
    设置对象超过多大时直接在老年代分配,默认值是0。

设置TLAB占eden区的比例

-XX:TLABWasteTargetPercent
    设置TLAB占eden区的百分比,默认值是1% 。 

设置是否优先YGC

-XX:+CollectGen0First
    设置FullGC时是否先YGC,默认值是false。
文章作者: 简凡丶
文章链接: http://yoursite.com/2020/07/15/6.%20JVM/JVM%E5%9F%BA%E7%A1%80/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 BestBear

评论