初识JVM,JVM自动内存管理

Posted 毛奇志(公众号:爱奇志)

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了初识JVM,JVM自动内存管理相关的知识,希望对你有一定的参考价值。

文章目录

一、前言

对于Java虚拟机在内存分配与回收的学习,如果读者大学时代没有偷懒的话,操作系统和计算机组成原理这两门功课学的比较好的话,理解起来JVM是比较容易的,只要底子还在,很多东西都可以触类旁通。

1.1 计算机==>操作系统==>JVM

JVM全称为Java Virtual Machine,译为Java虚拟机,读者会问,虚拟机虚拟的是谁呢?即虚拟是对什么东西的虚拟,即实体是什么,是如何虚拟的?下面让我们来看看“虚拟与实体”。

​关于计算机、操作系统、JVM三者关系,如下图:​

在这里插入图片描述

1.1.1 虚拟与实体(对上图的结构层次分析)

JVM之所以称为之虚拟机,是因为它是实现了计算机的虚拟化。下表展示JVM位于操作系统堆内存中,分别实现的了对操作系统和计算机的虚拟化。

在这里插入图片描述

操作系统栈对应JVM栈,操作系统堆对应JVM堆,计算机磁盘对应JVM方法区,存放字节码对象,计算机PC寄存器对应JVM程序计数器(注意:计算机PC寄存器是下一条指令地址,JVM程序计数器是当前指令的地址),

唯一不同的是,整个计算机(内存(操作系统栈+操作系统堆)+磁盘+PC计数器)对应JVM占用的整个内存(JVM栈+JVM堆+JVM方法区+JVM程序计数器)。

1.1.2 Java程序执行(对上图的箭头流程分析)

上图中不仅是结构图,展示JVM的虚拟和实体的关系,也是一个流程图,上图中的箭头展示JVM对一个对象的编译执行。

程序员写好的类加载到虚拟机执行的过程是:当一个classLoder启动的时候,classLoader的生存地点在JVM中的堆,首先它会去主机硬盘上将Test.class装载到JVM的方法区,方法区中的这个字节文件会被虚拟机拿来new Test字节码(),然后在堆内存生成了一个Test字节码的对象,最后Test字节码这个内存文件有两个引用一个指向Test的class对象,一个指向加载自己的classLoader。整个过程上图用箭头表示,这里做说明。

就像本文开始时说过的,有了计算机组成原理和操作系统两门课的底子,学起JVM的时候会容易许多,因为JVM本质上就是对计算机和操作系统的虚拟,就是一个虚拟机。

Java正是有了这一套虚拟机的支持,才成就了跨平台(一次编译,永久运行)的优势。

这样一来,前言部分我们成功引入JVM,接下来,本文要讲述的重点是JVM自动内存管理,先给出总述:

JVM自动内存管理=分配内存(指给对象分配内存)+回收内存(回收分配给对象的内存)

上面公式告诉我们,JVM自动内存管理分为两块,分配内存和回收内存

二、JVM内存空间与参数设置

2.1 运行时数据区

JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的运行时数据区域。这些运行时数据区包括方法区、堆、虚拟栈、本地方法栈、程序计数器,如图:

在这里插入图片描述

让我们一步步介绍,对于运行时数据区,很多博客都是使用顺序介绍的方式,不利于读者对比比较学习,这里笔者以表格的方式呈现:

程序计数器Java虚拟机栈本地方法栈Java 堆方法区
存放内容JVM字节码指令的地址或Undefined(如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined))局部变量表、操作数栈、动态链接、方法出口Native方法(本地方法)对象实例、数组类信息、常量、静态变量、即时编译器编译后的代码
用途字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。每一个本地方法的调用执行过程,就对应着一个栈帧从本地方法栈中入栈到出栈的过程。用于存放对象实例,被对象引用所指向存储一个类型所使用到的所有类型,域和方法的符号引用,在java程序的动态链接中起核心作用
线程共享还是私有线程私有线程私有线程私有线程间共享线程间共享
StackOverflowError栈溢出线程请求的栈深度大于虚拟机所允许的深度。报错信息:java.lang.StackOverflowError线程请求的栈深度大于虚拟机所允许的深度。报错信息:java.lang.StackOverflowError线程请求的栈深度大于虚拟机所允许的深度。报错信息:java.lang.StackOverflowError
OutOfMemoryError内存泄露如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。报错信息:java.lang.OutOfMemoryError:unable to create new native thread如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。报错信息:java.lang.OutOfMemoryError:unable to create new native thread如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。报错信息:java.lang.OutOfMemoryError: Java heap space当方法区无法满足内存分配需求,抛出该异常。报错信息:java.lang.OutOfMemoryError: PermGen space
特点是五个区域中唯一一个没有OutOfMemoryErrorJava虚拟机栈和本地方法栈都是方法调用栈,不同之处在于是一个是程序员编写的Java方法,一个是自带Native方法。Java虚拟机栈和本地方法栈都是方法调用栈,不同之处在于是一个是程序员编写的Java方法,一个是自带Native方法。1、可以位于物理上不连续的空间,但是逻辑上要连续。2、Java堆又称为CG堆,分为新生区和老年区,新生区又分为Eden区、From Survivor区和To Survivor又称为Non-Heap,非堆,与Java堆区分开来,

让我们对上表继续深入,讲述上表中的StackOverflowError和OutOfMemoryError。

对于运行时数据区的五个区域,如果讨论生命周期,一般讨论 堆 和 方法区,因为其他三个是线程私有的,生命周期很简单;

如果讨论垃圾回收算法和垃圾收集器,一般只讨论 堆,因为方法区里面存放的是要存活比较久的数据,其他两个栈和一个程序计数器仅保存了引用,只有堆中才是实际分配对象的,而要回收的就是对象;

如果讨论 栈溢出,只讨论本地方法栈和虚拟机栈,还有程序计数器;如果讨论 内存泄漏,讨论后面四个,唯独不讨论程序计数器。

方法区 是 堆 逻辑的一部分,存放一些 类信息、常量、静态变量、即时编译器编译后的代码 ,为什么这些东西放到 方法区 里面而不是放到堆里面,因为这些是用很久的,不用回收的,所以这里没有放到堆上(堆分为年轻代和老年代),经常要回收,所以里面只能放经常要回收的对象,又按照对象存活时间分为年轻代和老年代。

方法区JDK8之后变为直接内存,理由在于

2.2 关于StackOverflowError和OutOfMemoryError

2.2.1 StackOverflowError

运行时数据区中,抛出栈溢出的就是虚拟机栈和本地方法栈,

产生原因:线程请求的栈深度大于虚拟机所允许的深度。因为JVM栈深度是有限的而不是无限的,但是一般的方法调用都不会超过JVM的栈深度,如果出现栈溢出,基本上都是代码层面的原因,如递归调用没有设置出口或者无限循环调用。

解决方法:程序员检查代码是否有无限循环即可。

2.2.2 OutOfMemoryError

容易发生OutOfMemoryError内存溢出问题的内存空间包括:Permanent Generation space和Heap space。

1、第一种java.lang.OutOfMemoryError: PermGen space(方法区抛出)
产生原因:发生这种问题的原意是程序中使用了大量的jar或class,使java虚拟机装载类的空间不够,与Permanent Generation space有关。所以,根本原因在于jar或class太多,方法区堆溢出,则解决方法有两个种,要么增大方法区,要么减少jar、class文件,且看解决方法。

解决方法:

  1. 从增大方法区方面入手:

增加java虚拟机中的XX:PermSize和XX:MaxPermSize参数的大小,其中XX:PermSize是初始永久保存区域大小,XX:MaxPermSize是最大永久保存区域大小。

如web应用中,针对tomcat应用服务器,在catalina.sh 或catalina.bat文件中一系列环境变量名说明结束处增加一行:
JAVA_OPTS=" -XX:PermSize=64M -XX:MaxPermSize=128m"
可有效解决web项目的tomcat服务器经常宕机的问题。
2. 从减少jar、class文件入手:

清理应用程序中web-inf/lib下的jar,如果tomcat部署了多个应用,很多应用都使用了相同的jar,可以将共同的jar移到tomcat共同的lib下,减少类的重复加载。

2、第二种OutOfMemoryError: Java heap space(堆抛出)
产生原因:发生这种问题的原因是java虚拟机创建的对象太多,在进行垃圾回收之间,虚拟机分配的到堆内存空间已经用满了,与Heap space有关。所以,根本原因在于对象实例太多,Java堆溢出,则解决方法有两个种,要么增大堆内存,要么减少对象示例,且看解决方法。

解决方法:

1.从增大堆内存方面入手:

增加Java虚拟机中Xms(初始堆大小)和Xmx(最大堆大小)参数的大小。如:set JAVA_OPTS= -Xms256m -Xmx1024m

2.从减少对象实例入手:

一般来说,正常程序的对象,堆内存时绝对够用的,出现堆内存溢出一般是死循环中创建大量对象,检查程序,看是否有死循环或不必要地重复创建大量对象。找到原因后,修改程序和算法。

3、第三种OutOfMemoryError:unable to create new native thread(Java虚拟机栈、本地方法栈抛出)

产生原因:这个异常问题本质原因是我们创建了太多的线程,而能创建的线程数是有限制的,导致了异常的发生。能创建的线程数的具体计算公式如下:

(MaxProcessMemory - JVMMemory - ReservedOsMemory) / (ThreadStackSize) = Number of threads

注意:MaxProcessMemory 表示一个进程的最大内存,JVMMemory 表示JVM内存,ReservedOsMemory 表示保留的操作系统内存,ThreadStackSize 表示线程栈的大小。

在java语言里, 当你创建一个线程的时候,虚拟机会在JVM内存创建一个Thread对象同时创建一个操作系统线程,而这个系统线程的内存用的不是JVMMemory,而是系统中剩下的内存(MaxProcessMemory - JVMMemory - ReservedOsMemory)。由公式得出结论:你给JVM内存越多,那么你能创建的线程越少,越容易发生 java.lang.OutOfMemoryError: unable to create new native thread

解决方法:

1.如果程序中有bug,导致创建大量不需要的线程或者线程没有及时回收,那么必须解决这个bug,修改参数是不能解决问题的。
2.如果程序确实需要大量的线程,现有的设置不能达到要求,那么可以通过修改MaxProcessMemory,JVMMemory,ThreadStackSize这三个因素,来增加能创建的线程数:MaxProcessMemory 表示使用64位操作系统,VMMemory 表示减少 JVMMemory 的分配,ThreadStackSize 表示减小单个线程的栈大小。

2.3 JVM堆内存和非堆内存

2.3.1 堆内存和非堆内存

JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)。

年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。

堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。

非堆内存用途:永久代,也称为方法区,存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。

在JDK1.8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代上类似,都是方法区的实现,他们最大区别是:永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制。在后面的实践中,因为笔者使用的是JDK8,所以打印出的GC日志里面就有MetaSpace。

2.3.2 JVM堆内部构型(新生代和老年代)

Jdk8中已经去掉永久区,这里为了与时俱进,不再赘余。

在这里插入图片描述

上图演示Java堆内存空间,分为新生代和老年代,分别占Java堆1/3和2/3的空间,新生代中又分为Eden区、Survivor0区、Survivor1区,分别占新生代8/10、1/10、1/10空间。

问题1:什么是Java堆?

回答1:JVM规范中说到:”所有的对象实例以及数组都要在堆上分配”。Java堆是垃圾回收器管理的主要区域,百分之九十九的垃圾回收发生在Java堆,另外百分之一发生在方法区,因此又称之为”GC堆”。根据JVM规范规定的内容,Java堆可以处于物理上不连续的内存空间中。

问题2:为什么Java堆要分为新生代和老年代?

回答2:当前JVM对于堆的垃圾回收,采用分代收集的策略。根据堆中对象的存活周期将堆内存分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活。而老年代中存放的对象存活率高。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

问题3:为什么新生代要分为Eden区、Survivor0区、Survivor1区?

回答3:这是结构与策略相适应的原则,新生代垃圾收集使用的是复制算法(一种垃圾收集算法,Serial收集器、ParNew收集器、Parallel scavenge收集器都是用这种算法),复制算法可以很好的解决垃圾收集的内存碎片问题,但是有一个天然的缺陷,就是要牺牲一半的内存(即任意时刻只有一半内存用于工作),这对于宝贵的内存资源来说是极度奢侈的。新生代在使用复制算法作为其垃圾收集算法的时候,对其做了优化,拿出2/10的新生代的内存作为交换区,称为Survivor0区和Survivor1区(注意:有的博客上称为From Survivor Space和To Survivor Space,这样阐述也是对的,但是容易对初学者形成误导,因为在复制算法中,复制是双向的,没有固定的From和To,这一次是由这一边到另一边,下次就是从另一边到这一边,使用From Survivor Space和To Survivor Space容易让后来学习者误以为复制只能从一边到另一边,当然有的博客中会附加不管从哪边到哪边,起始就是From,终点就是To,即From Survivor Space和To Survivor Space所对应的区循环对调,但是读者不一定想的明白。所以笔者这里使用Survivor0、Survivor1,减少误解)

所以说,新生代在结构上分为Eden区、Survivor0区、Survivor1区,是与其使用的垃圾收集算法(复制算法)相适应的结果。

问题4:关于永久区Permanent Space?

回答4:Jdk8中取消了永久区Permanent Space,使用 元数据空间metaspace,使用直接内存…

问题:什么是老年代担保机制?
回答:因为新生代和老年代是1:2,如果在eden区放不下,会放到老年区,如果minor gc的时候,survivor区放不下,也会放到老年区,所有,有时候会在老年区里面有 不少gc年龄比较小 的大对象,就是因为年轻代放不下了,老年代担保机制多次触发会增加老年代负担,过早地触发major gc,说明当前的 eden survivor 比例设置不太好。

问题:为什么eden:survivor1:survivor2=8:1:1?
回答:这个可以设置的,VM Options: -XX:SurvivorRatio=8 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Survivor=8:1。如果eden区域占比小,那么minor gc会比较频繁,gc线程是占用cpu资源的,是stop the world的,不好;如果eden区域占比大,则survivor区域变小了,survivor区满了也会触发老年代担保机制。

Minor GC触发条件:eden区满时,触发MinorGC。即申请一个对象时,发现eden区不够用,则触发一次MinorGC。在MinorGC时,将小于 to space大小的存活对象复制到 to space(如果to space区域不够,则利用担保机制进入老年代区域),然后to space和from space换位置,所以我们看到的to space一直是空的。

问题:为什么年轻代age是0~15,到了16就移动到老年代?
回答:对象头中用四个bit位存放分代年龄,所以就是 0~15。

无论是对象大小还是对象年轻,进入老年代的阈值都是可以用参数设置的,VM Options: -XX:PretenureSizeThreshold=3145728,表示大于3MB都到老年代中去;VM Options: -XX:MaxTenuringThreshold=2,表示经历两次Minor GC,就到老年代中去。

问题:虚拟机怎么知道哪个对象要回收,哪个对象不回收?
回答:两种方式:要么 引用计数 ,要么 根节点+可达性分析。
引用计数:这种方式有循环引用的问题,Java中不使用,python是使用。
解释一下引用计数,一般来说,java要回收的对象要求是没有引用指向的,就是程序中没有了用的对象,才可以回收,要求gc不影响程序。看一个循环引用的问题:

public class Main1 {
    public static void main(String[] args) {
        A a = new A();
        B b = new B();   // 这个时候 new A()对象和new B()对象引用计数为1,就是a b
        a.instance = b;
        b.instance = a;   // 这个时候 new A()对象和new B()对象引用计数为2,就是a b a.instance b.instance
        a = null;
        b = null;  // 这个时候 new A()对象和new B()对象已经没有引用了,但是引用计数仍然为1,instance还在指向
    }
}

class A {
     B instance;
}

class B {
     A instance;
}

对于 根节点+可达性分析,确定若干个根节点,从根节点出发,可以达到的就是可达的引用,所指向的对象不可回收,反之可以回收。如图:

在这里插入图片描述
obj1 引用指向对象1,obj2 引用指向对象2,obj3 引用指向对象3,而对象3 中又引用 对象4,对象5 不是 gc root,所以 对象5 和 对象6 都在可达性链 中。最终,对象1 对象2 对象3 对象4 都是可达的,不会被垃圾收集器回收,对象5 对象6 是不可达的,要被垃圾收集器回收。

2.4 JVM堆参数设置

这些都是和堆内存分配有关的参数,所以我们放在第二部分了,和垃圾收集器有关的参数放在第四部分。

举例:java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m

2.4.1 JVM重要参数

因为整个堆大小=年轻代大小(新生代大小) + 年老代大小 + 持久代大小,

-Xmn2g:表示年轻代大小为2G。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。

-XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。这里设置为4,表示年轻代与年老代所占比值为1:4,又因为上面设置年轻代为2G,则老年代大小为8G

-XX:SurvivorRatio=8:设置年轻代中Eden区与Survivor区的大小比值。这里设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10

则Eden:Survivor0:Survivor1=8:1:1

-XX:MaxPermSize=16m:设置持久代大小为16m。

所有整个堆大小=年轻代大小 + 年老代大小 + 持久代大小= 2G+ 8G+ 16M=10G+6M=10246MB

2.4.2 JVM其他参数

-Xmx3550m:设置JVM最大可用内存为3550M。
-Xms3550m:设置JVM促使内存为3550m,此值可以设置与-Xmx相同。

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

关于为什么-xmx与-xms的大小设置为一样的?

首先,在Java堆内存分配中,-xmx用于指定JVM最大分配的内存,-xms用于指定JVM初始分配的内存,所以,-xmx与-xms相等表示JVM初次分配的内存的时候就把所有可以分配的最大内存分配给它(指JVM),这样的做的好处是:

  1. 避免JVM在运行过程中、每次垃圾回收完成后向OS申请内存:因为所有的可以分配的最大内存第一个就给它(JVM)了。

  2. 延后启动后首次GC的发生时机、减少启动初期的GC次数:因为第一次给它分配了最大的;

  3. 尽可能避免使用swap space:swap space为交换空间,当web项目部署到linux上时,有一条调优原则就是“尽可能使用内存而不是交换空间”

4.设置堆内存为不可扩展和收缩,避免在每次GC 后调整堆的大小

影响堆内存扩展与收缩的两个参数

MaxHeapFreeRadioMinHeapFreeRadio
默认值为70默认值为40
当xmx值比xms值大,堆可以动态收缩与扩展,这个参数控制当堆空间大于指定比例时会自动收缩,默认表示堆空间大于70%会自动收缩当xmx值比xms值大,堆可以动态收缩与扩展,这个参数控制当堆空间小于指定比例时会自动扩展,默认表示堆空间小于40%会自动扩展

由上表可知,堆内存默认是自动扩展和收缩的,但是有一个前提条件,就是到xmx比xms大的时候,当我们将xms设置为和xmx一样大,堆内存就不可扩展和收缩了,即整个堆内存被设置为一个固定值,避免在每次GC 后调整堆的大小。

附加:在Java非堆内存分配中,一般是用永久区内存分配:

JVM 使用 -XX:PermSize 设置非堆内存初始值,由 -XX:MaxPermSize 设置最大非堆内存的大小。

2.5 从日志看JVM(开发实践)

在这里插入图片描述

这里了设置GC日志关联的类和将GC日志打印

在这里插入图片描述

如程序所述,申请了10MB的空间,allocation1 2MB+allocation2 2MB+allocation3 2MB+allocation4 4MB=10MB

接下来我们开始阅读GC日志,这里笔者以自己电脑上打印的GC日志为例,讲述阅读GC日志的方法:

heap表示堆,即下面的日志是对JVM堆内存的打印;

因为使用的是jdk8,所以默认使用ParallelGC收集器,也就是在新生代使用Parallel Scavenge收集器,老年代使用ParallelOld收集器

PSYoungGen 表示使用Parallel scavenge收集器作为年轻代收集器,ParOldGen表示使用Parallel old收集器作为老年代收集器,即笔者电脑上默认是使用Parallel scavenge+Parallel old收集器组合。

其中,PSYoungGen总共38400K(37.5MB),被使用了13568K(13.25MB),PSYoungGen又分为Eden Space 33280K(32.5MB) 被使用了40% 13MB,from space 5120K(5MB)和to space 5120K(5MB),这就是一个eden区和两个survivor区。

此处注意,因为使用的是jdk8,所以没有永久区了,只有MetaSpace,见上图。

三、HotSpot VM

3.1 HotSpot VM相关知识

问题一:什么是HotSpot虚拟机?HotSpot VM的前世今生?

回答一:HotSpot VM是由一家名为“Longview Technologies”的公司设计的一款虚拟机,Sun公司收购Longview Technologies公司后,HotSpot VM成为Sun主要支持的VM产品,Oracle公司收购Sun公司后,即在HotSpot的基础上,移植JRockit的优秀特性,将HotSpot VM与JRockit VM整合到一起。

问题二:HotSpot VM有何优点?

回答二:HotSpot VM的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。 通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。

问题三:HotSpot VM与JVM是什么关系?

回答三:今天的HotSpot VM,是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

3.2 HotSpot VM的两个实现与查看本机HotSpot

HotSpot VM包括两个实现,不同的实现适合不同的场景:

Java HotSpot Client VM:通过减少应用程序启动时间和内存占用,在客户端环境中运行应用程序时可以获得最佳性能。此经过专门调整,可缩短应用程序启动时间和内存占用,使其特别适合客户端环境。此jvm实现比较适合我们平时用作本地开发,平时的开发不需要很大的内存。

Java HotSpot Server VM:旨在最大程度地提高服务器环境中运行的应用程序的执行速度。此jvm实现经过专门调整,可能是特别调整堆大小、垃圾回收器、编译器那些。用于长时间运行的服务器程序,这些服务器程序需要尽可能快的运行速度,而不是快速启动时间。

只要电脑上安装jdk,我们就可以看到hotspot的具体实现:

在这里插入图片描述

四、JVM内存回收

我们知道,Java中是没有析构函数的,既然没有析构函数,那么如何回收对象呢,答案是自动垃圾回收。Java语言的自动回收机制可以使程序员不用再操心对象回收问题,一切都交给JVM就好了。那么JVM又是如何做到自动回收垃圾的呢,且看本节,本节分为两个部分——垃圾收集算法和垃圾收集器,其中,收集算法是内存回收的理论,而垃圾回收器是内存回收的实践。

垃圾收集策略两个目的:gc次数少,gc时间短,不同的收集算法和收集器侧重不同。

4.1 垃圾收集算法(内存回收理论)

4.1.1 标记-清除算法

标记-清除算法分为两个阶段,“标记”和“清除”,

标记:首先标记出所有需要回收的对象;

清除:在标记完成后统一回收所有被标记的对象。

“标记-清除”算法的不足:第一,效率问题,标记和清除两个过程的效率都不会太高;第二,空间问题,标记清除后产生大量不连续的内存碎片,这些内存空间碎片可能会导致以后程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作,如果很容易出现这样的空间碎片多、无法找到大的连续空间的情况,垃圾收集就会较为频繁。

4.1.2 复制算法

为了解决“标记-清除算法”的效率问题,一种复制算法产生了,它将当前可用内存按容量划分为大小相等的两块,每次只使用其中一块。当一块的内存用完了,就将还活着的对象复制到另一块上面,然后再把已使用的内存空间一次清除掉。这样使得每次都对整个半区进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。

这种算法处理内存碎片的核心在于将整个半块中活的的对象复制到另一整个半块上面去,所以称为复制算法。

附:关于复制算法的改进

复制算法合理的解决了内存碎片问题,但是却要以牺牲一半的宝贵内存为代价,这是非常让人心疼的。令人愉快地是,现代虚拟机中,早就有了关于复制算法的改进:

对于Java堆中新生代中的对象来说,99%的对象都是“朝升夕死”的,就是说很多的对象在创建出来后不久就会死掉了,所有我们可以大胆一点,不需要按照1:1的比例来划分内存空间,而是将新生代的内存划分为一块较大的Eden区(一般占新生代8/10的大小)和两块较小的Survivor区(用于复制,一般每块占新生代1/10的大小,两块占新生代2/10的大小)。当回收时,将Eden区和Survivor里面当前还活着的对象全部都复制到另一块Survivor中(关于另一个块Survivor是否会溢出的问题,答案是不会,这里将新生代90%的容量里的对象复制到10%的容量里面,确实是有风险的,但是JVM有一种内存的分配担保机制,即当目的Survivor空间不够,会将多出来的对象放到老年代中,因为老年代是足够大的),最后清理Eden区和源Survivor区的空间。这样一来,每次新生代可用内存空间为整个新生代90%,只有10%的内存被浪费掉,

正是因为这一特性,现代虚拟机中采用复制算法来回收新生代,如Serial收集器、ParNew收集器、Parallel scavenge收集器均是如此。

4.1.3 标志-整理算法(复制算法变更后在老年代的应用)

对于新生代来说,由于具有“99%的对象都是朝生夕死的”这一特点,所以我们可以大胆的使用10%的内存去存放90%的内存中活着的对象,即使是目的Survivor的容量不够,也可以将多余的存放到老年代中(担保机制),所有对于新生代,我们使用复制算法是比较好的(Serial收集器、ParNew收集器、Parallel scavenge收集器)。

但是对于老年代,没有大多数对象朝生夕死这一特点,如果使用复制算法就要浪费一半的宝贵内存,所有我们用另一种办法来处理它(指老年代)——标志-整理算法。

标记-整理算法分为两个阶段,“标记”和“整理”,

标记:首先标记出所有需要回收的对象(和标记-清除算法一样);

整理:在标记完成后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存(向一端移动类似复制算法)。

区别:标志-清除算法包括先标志,后清除两个过程,标志-整理算法包括先标志,后清除,再整理三个过程。

4.1.4 分代收集算法

当前商业虚拟机都是的垃圾收集都使用“分代收集”算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采取最适当的收集算法。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量对象存活,就是使用复制算法,这样只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象的存活率高、没有额外空间对其分配担保(新生代复制算法如果目的Survivor容量不够会将多余对象放到老年代中,这就是老年代对新生代的分配担保),必须使用“标记-清除算法”或“标记-整理算法”来回收。

新生代的minor gc频率较高,复制算法正好浪费一半空间,不用整理内存空间碎片,以空间换时间;
老年代的major gc频率较低,“标记-整理算法”包括先标志、再清除,最后整理,整理内存碎片需要时间,但是整个算法不浪费空间,以时间换空间。

四种常用算法优缺点比较、用途比较

在这里插入图片描述

4.2 垃圾收集器(内存回收实践)

有了上面的垃圾回收算法,就有了很多的垃圾回收器。对于垃圾回收器,很少有表格对比,笔者以表格对比的方式呈现:

单线程or多线程新生代or老年代基于的收集算法备注
Serial收集器单线程新生代复制算法优点:简单; 缺点:Stop the world,垃圾收集时要停掉所有其他线程; 常用组合:Serial + serial old 新生代和老年代都是单线程,简单
ParNew收集器(是Serial收集器的多线程版本)多线程新生代复制算法优点:相对于Serial收集器,使用了多线程; 缺点:除了多线程,其他所有和Serial收集器一样; 常用组合:ParNew+ serial old 新生代多线程,老年代单线程,简单(新生代ParNew收集器仅仅是Serial收集器的多线程版本,所有该组合相对于Serial + serial old 只是新生代是多线程而已,其余不变)
Parallel scavenge收集器(吞吐量优先收集器)多线程新生代复制算法设计目标:尽可能达到一个可控制的吞吐量; 吞吐量=运行用户代码时间/(运行用户代码时间+来及收集时间); 优点:吞吐量高,可以高效率地利用CPU时间,尽快完成程序的计算任务,适合后台运算; 缺点:没有太大缺陷; 常用组合:Parallel scavenge + Parallel old 该组合完成吞吐量优先虚拟机,适用于后台计算;
serial old收集器(是Serial收集器的老年代版本)单线程老年代标记-整理算法优点:简单; 缺点:Stop the world,垃圾收集时要停掉所有其他线程; 常用组合:Serial + serial old 新生代和老年代都是单线程,简单;
Parallel old收集器(是Parallel scavenge收集器的老年代版本)多线程老年代标记-整理算法优点:吞吐量高,可以高效率地利用CPU时间,尽快完成程序的计算任务,适合后台运算; 缺点:没有太大缺陷; 常用组合:Parallel scavenge + Parallel old 该组合完成吞吐量优先虚拟机,适用于后台计算;
cms收集器(并发低停顿收集器)多线程老年代标记-清除算法优点:停顿时间短,适合与用户交互的程序;四个步骤:初始标记 CMS initial mark、并发标记 CMS concurrent mark、重新标记 CMS remark、并发清除 CMS concurrent sweep;常用组合:cms收集器 完成响应时间短虚拟机,适用于用户交互;
G1收集器多线程新生代+老年代标记-整理算法面向服务端的垃圾回收器。特点:并行与并发、分代收集、空间整合、可预测的停顿;四个步骤:初始标记 Initial Marking、并发标记 Concurrent Marking、最终筛选 Final Marking 、筛选回收 Live Data Counting and Evacuation;常用组合:G1收集器 面向服务端的垃圾回收器注意:G1收集器的收集算法加粗了,这里做出说明,G1收集器从整体上来看是基于“标记-整理”算法实现的收集器,从局部(两个region之间)上看来是基于“复制”算法实现的。

注意:G1收集器的收集算法加粗了,这里做出说明,G1收集器从整体上来看是基于“标记-整理”算法实现的收集器,从局部(两个region之间)上看来是基于“复制”算法实现的。

从上表可以得到的收集常用组合包括:

常用组合1:Serial + serial old 新生代和老年代都是单线程,简单

常用组合2:ParNew+ serial old 新生代多线程,老年代单线程,简单

常用组合3:Parallel scavenge + Parallel old 该组合完成吞吐量优先虚拟机,适用于后台计算

常用组合4:cms收集器 完成响应时间短虚拟机,适用于用户交互

常用组合5:G1收集器 面向服务端的垃圾回收器

4.2.1 常用组合1:Serial + serial old 新生代和老年代都是单线程,简单

在这里插入图片描述

附:图上有一个safepoint,译为安全点(有的博客上写成了savepoint,是错误的,至少是不准确的),这个safepoint干什么的呢?如何确定这个safepoint的位置?

这个safepoint是干什么的?

safepoint的定义是“A point in program where the state of execution is known by the VM”,译为程序中一个点就是虚拟机所知道的一个执行状态。

JVM中safepoint有两种,分别为GC safepoint、Deoptimization safepoint:

GC safepoint:用在垃圾收集操作中,如果要执行一次GC,那么JVM里所有需要执行GC的Java线程都要在到达GC safepoint之后才可以开始GC;

Deoptimization safepoint:如果要执行一次deoptimization,那么JVM里所有需要执行deoptimization的Java线程都要在到达deoptimization safepoint之后才可以开始deoptimize

我们上图中的safepoint自然是GC safepoint,所以上图中的两个safepoint都是指执行GC线程前的状态。

对于上图的理解是(很多博客上都有这种运行示意图,但是没有加上解释,笔者这里加上):

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、四个线程都执行新生代的GC操作,因为使用的是Serial收集器,所以是基于复制算法的单线程GC,而且要Stop the world,所以只有GC线程在执行,四个用户线程都停止了。

3、新生代GC操作完成,四个线程继续执行,过了一会儿,要开始执行老年代的GC操作了,所以四个线程都要再次达到GC safepoint点,先到的要等待晚到的,图中都达到了;

4、四个线程都执行老年代的GC操作,因为使用的是Serial Old收集器,所以是基于标志-整理算法的单线程GC,而且要Stop the world,所以只有GC线程在执行,四个用户线程都停止了。

5、老年代GC操作完成,四个线程继续执行。

4.2.2 常用组合2:ParNew+ serial old 新生代多线程,老年代单线程,简单

在这里插入图片描述

该组合中新生代ParNew收集器仅仅是Serial收集器的多线程版本,所有该组合相对于Serial + serial old 只是新生代是多线程而已,其余不变

对于上图的理解是(很多博客上都有这种运行示意图,但是没有加上解释,笔者这里加上):

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、四个线程都执行新生代的GC操作,因为使用的是Parnew收集器,所以是基于复制算法的多线程GC(注意,这里的多线程GC,是指多个GC线程并发,用户线程还是要停止的)所以还是要Stop the world,所以只有GC线程在执行,四个用户线程都停止了。

3、新生代GC操作完成,四个线程继续执行,过了一会儿,要开始执行老年代的GC操作了,所以四个线程都要再次达到GC safepoint点,先到的要等待晚到的,图中都达到了;

4、四个线程都执行老年代的GC操作,因为使用的是Serial Old收集器,所以是基于标志-整理算法的单线程GC,而且要Stop the world,所以只有GC线程在执行,四个用户线程都停止了。

5、老年代GC操作完成,四个线程继续执行。

4.2.3 常用组合3:Parallel scavenge + Parallel old 新生代和老年代都是多线程,该组合完成吞吐量优先虚拟机,适用于后台计算

在这里插入图片描述

对于上图的理解是:

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、四个线程都执行新生代的GC操作,因为使用的是Parallel scavenge收集器,所以是基于复制算法的多线程GC(注意,这里的多线程GC,是指多个GC线程并发,用户线程还是要停止的)所以只有GC线程在执行,四个用户线程都停止了。

3、新生代GC操作完成,四个线程继续执行,过了一会儿,要开始执行老年代的GC操作了,所以四个线程都要再次达到GC safepoint点,先到的要等待晚到的,图中都达到了;

4、四个线程都执行老年代的GC操作,因为使用的是Parallel Old收集器,所以是基于标志-整理算法的多线程GC,(注意,这里的多线程GC,是指多个GC线程并发,用户线程还是要停止的)所以只有GC线程在执行,四个用户线程都停止了。

5、老年代GC操作完成,四个线程继续执行。

4.2.4 常用组合4:cms收集器 多线程,完成响应时间短虚拟机,适用于用户交互

在这里插入图片描述

对于上图的理解是:

CMS收集包括四个步骤:初始标记、并发标记、重新标记、并发清除(CMS作为标记-清除收集器,三个标记一个清除)

在这里插入图片描述

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、四个线程都执行GC操作,因为使用的是CMS收集器,第一步骤是初始标记,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,GC的标记阶段需要stop the world,让所有Java线程挂起,这样JVM才可以安全地来标记对象。所以只有“初始标记”在执行,四个用户线程都停止了。初始标记完成后,达到第二个GC safepoint,图中达到了;

3、开始执行并发标记,并发标记是GCRoot开始对堆中的对象进行可达性分析,找出存活的对象,并发标记可以与用户线程一起执行,并发标记完成后,所有线程达到下一个GC safepoint,图中达到了;

4、开始执行重新标记,重新标记是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那部分标记记录,

重新标记完成后,所有线程达到下一个GC safepoint,图中达到了;

5、开始执行并发清理,并发清理可以与用户线程一起执行,并发清理完成后,所有线程达到下一个GC safepoint,图中达到了;

6、开始重置线程,就是对刚才并发标记操作的对象,图中是线程3(注意:重置线程针对的是并发标记的线程,没有被并发标记的线程不需要重置线程操作),重置操作线程3的时候,与其他三个用户线程无关,它们可以一起执行。

CMS为什么是多线程收集器?

因为CMS收集器整个过程中耗时最长的第二并发标记和第四并发清除过程中,GC线程都可以与用户线程一起工作,初始标记和重新标记时间忽略不计,所以,从总体上来说,cms收集器的内存回收过程与用户线程是并发执行的,所以上表中CMS为多线程收集器。

4.2.5 常用组合5:G1收集器 多线程,面向服务端的垃圾回收器

1、什么是G1?

G1就是Gabage-First,它将整个Java堆划分为多个大小相等的独立区域,即Region,虽然还保留新生代和老年代的概念,但新生代和老年代已不再物理隔离,它们都是一部分Region的集合。

G1收集器的底层原理:G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次依据允许的收集时间,优先收集回收价值最大的Region。正是这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以获取尽可能高的效率。

G1收集器运行示意图如下:

在这里插入图片描述

对于上图的理解是:

G1收集包括四个步骤:初始标记、并发标记、最终筛选、筛选回收

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、开始执行初始标记,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一个阶段用户程序并发标记时,能在正确可用的Region上创建新对象,整个标记阶段需要stop the world,让所有Java线程挂起,这样JVM才可以安全地来标记对象。所以只有“初始标记”在执行,四个用户线程都停止了。初始标记完成后,达到第二个GC safepoint,图中达到了;

3、开始执行并发标记,并发标记是GCRoot开始对堆中的对象进行可达性分析,找出存活的对象,并发标记可以与用户线程一起执行,并发标记完成后,所有线程(GC线程、用户线程)达到下一个GC safepoint,图中达到了;

4、开始执行最终标记,最终标记是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那部分标记记录,最终标记完成后,所有线程达到下一个GC safepoint,图中达到了;

5、开始执行筛选回收,筛选回归首先对各个Region的回收价值和成本排序, 根据用户期待的GC停顿时间来制定回收计划,筛选回收过程中,因为停顿用户线程将大幅提高收集效率,所以一般筛选回归是停止用户线程的,筛选回归完成后,所有线程达到下一个GC safepoint,图中达到了;

6、G1收集器收集结束,继续并发执行用户线程。

4.3 垃圾收集器常用参数

(笔者这里加上idea上如何使用这些参数,这些是垃圾收集器的参数,所以这里放到第四部分,在本文第五部分内存分配我们会用到)

参数idea中使用方式描述
UseSerialGCVM Options:-XX:+UseSerialGC虚拟机运行在Client模式下的默认值,打开此开关之后,使用Serial+Serial Old的收集器组合进行内存回收
UseParNewGCVM Options: -XX:+UseParNewGC打开此开关之后,使用ParNew+ Serial Old的收集器组合进行内存回收
UseConcMarkSweepGCVM Options: -XX:+UseConcMarkSweepGC打开此开关之后,使用ParNew + CMS+ Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用
UseParallelGCVM Options: -XX:+UseParallelGC虚拟机运行在Server模式下的默认值,打开此开关之后,使用Parallel Scavenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收
UseParallelOldGCVM Options: -XX:UseParallelOldGC打开此开关后,使用Parallel Scavenge + Parallel Old 的收集器组合进行内存回收
SurvivorRatioVM Options: -XX:SurvivorRatio=8新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Survivor=8:1
PretenureSizeThresholdVM Options: -XX:PretenureSizeThreshold=3145728,表示大于3MB都到老年代中去直接晋升到老年代的对象大小,设置这个参数后,这个参数以字节B为单位大于这个参数的对象将直接在老年代中分配
MaxTenuringThresholdVM Opti

以上是关于初识JVM,JVM自动内存管理的主要内容,如果未能解决你的问题,请参考以下文章

初识JVM,JVM自动内存管理

初识JVM,JVM自动内存管理

初识JVM,JVM自动内存管理

初识JVM,JVM自动内存管理

初识JVM,JVM自动内存管理

JVM内存初识

(c)2006-2024 SYSTEM All Rights Reserved IT常识